Pull request 2053: 6357-auth-log-remote-ip

Updates #6357.

Squashed commit of the following:

commit 0d375446204d126d3fc20db0a0718e849112450b
Merge: 61858bdec 52713a260
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Nov 3 14:47:10 2023 +0300

    Merge branch 'master' into 6357-auth-log-remote-ip

commit 61858bdec27f9efb35c6fa5306ace1c0053300ca
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Nov 3 14:44:58 2023 +0300

    all: upd chlog

commit 1eef67261ff1e4eb667e11a58a5fe1f9b1dbdd7c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Nov 2 19:20:41 2023 +0300

    home: imp code

commit 2956aed9054309ab15dc9e61bcae59b76ccd5930
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Thu Nov 2 16:10:07 2023 +0300

    home: imp docs

commit ca0f53d7c28d17287d80c0c5d1d76b21506acb64
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Oct 31 15:08:37 2023 +0300

    home: imp code

commit 6b11b461180f1ee7528ffbaf37d5e76a1a7f208a
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Oct 27 19:45:55 2023 +0300

    home: auth log remote ip
This commit is contained in:
Stanislav Chzhen 2023-11-03 16:07:15 +03:00
parent 52713a2600
commit f3817e4411
7 changed files with 487 additions and 450 deletions

View File

@ -28,6 +28,10 @@ NOTE: Add new changes BELOW THIS COMMENT.
- Ability to specify multiple domain specific upstreams per line, e.g. - Ability to specify multiple domain specific upstreams per line, e.g.
`[/domain1/../domain2/]upstream1 upstream2 .. upstreamN` ([#4977]). `[/domain1/../domain2/]upstream1 upstream2 .. upstreamN` ([#4977]).
### Changed
- Improved authentication failure logging ([#6357]).
### Fixed ### Fixed
- `$important,dnsrewrite` rules do not take precedence over allowlist rules - `$important,dnsrewrite` rules do not take precedence over allowlist rules
@ -39,6 +43,7 @@ NOTE: Add new changes BELOW THIS COMMENT.
[#6204]: https://github.com/AdguardTeam/AdGuardHome/issues/6204 [#6204]: https://github.com/AdguardTeam/AdGuardHome/issues/6204
[#6329]: https://github.com/AdguardTeam/AdGuardHome/issues/6329 [#6329]: https://github.com/AdguardTeam/AdGuardHome/issues/6329
[#6335]: https://github.com/AdguardTeam/AdGuardHome/issues/6335 [#6335]: https://github.com/AdguardTeam/AdGuardHome/issues/6335
[#6357]: https://github.com/AdguardTeam/AdGuardHome/issues/6357
<!-- <!--
NOTE: Add new changes ABOVE THIS COMMENT. NOTE: Add new changes ABOVE THIS COMMENT.

View File

@ -4,32 +4,17 @@ import (
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"path"
"strconv"
"strings"
"sync" "sync"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/httphdr"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/netutil"
"github.com/AdguardTeam/golibs/timeutil"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// cookieTTL is the time-to-live of the session cookie.
const cookieTTL = 365 * timeutil.Day
// sessionCookieName is the name of the session cookie.
const sessionCookieName = "agh_session"
// sessionTokenSize is the length of session token in bytes. // sessionTokenSize is the length of session token in bytes.
const sessionTokenSize = 16 const sessionTokenSize = 16
@ -69,7 +54,7 @@ func (s *session) deserialize(data []byte) bool {
// Auth - global object // Auth - global object
type Auth struct { type Auth struct {
db *bbolt.DB db *bbolt.DB
raleLimiter *authRateLimiter rateLimiter *authRateLimiter
sessions map[string]*session sessions map[string]*session
users []webUser users []webUser
lock sync.Mutex lock sync.Mutex
@ -77,6 +62,8 @@ type Auth struct {
} }
// webUser represents a user of the Web UI. // webUser represents a user of the Web UI.
//
// TODO(s.chzhen): Improve naming.
type webUser struct { type webUser struct {
Name string `yaml:"name"` Name string `yaml:"name"`
PasswordHash string `yaml:"password"` PasswordHash string `yaml:"password"`
@ -88,7 +75,7 @@ func InitAuth(dbFilename string, users []webUser, sessionTTL uint32, rateLimiter
a := &Auth{ a := &Auth{
sessionTTL: sessionTTL, sessionTTL: sessionTTL,
raleLimiter: rateLimiter, rateLimiter: rateLimiter,
sessions: make(map[string]*session), sessions: make(map[string]*session),
users: users, users: users,
} }
@ -216,8 +203,8 @@ func (a *Auth) storeSession(data []byte, s *session) bool {
return true return true
} }
// remove session from file // removeSessionFromFile removes a stored session from the DB file on disk.
func (a *Auth) removeSession(sess []byte) { func (a *Auth) removeSessionFromFile(sess []byte) {
tx, err := a.db.Begin(true) tx, err := a.db.Begin(true)
if err != nil { if err != nil {
log.Error("auth: bbolt.Begin: %s", err) log.Error("auth: bbolt.Begin: %s", err)
@ -279,7 +266,7 @@ func (a *Auth) checkSession(sess string) (res checkSessionResult) {
if s.expire <= now { if s.expire <= now {
delete(a.sessions, sess) delete(a.sessions, sess)
key, _ := hex.DecodeString(sess) key, _ := hex.DecodeString(sess)
a.removeSession(key) a.removeSessionFromFile(key)
return checkSessionExpired return checkSessionExpired
} }
@ -301,351 +288,17 @@ func (a *Auth) checkSession(sess string) (res checkSessionResult) {
return checkSessionOK return checkSessionOK
} }
// RemoveSession - remove session // removeSession removes the session from the active sessions and the disk.
func (a *Auth) RemoveSession(sess string) { func (a *Auth) removeSession(sess string) {
key, _ := hex.DecodeString(sess) key, _ := hex.DecodeString(sess)
a.lock.Lock() a.lock.Lock()
delete(a.sessions, sess) delete(a.sessions, sess)
a.lock.Unlock() a.lock.Unlock()
a.removeSession(key) a.removeSessionFromFile(key)
} }
type loginJSON struct { // addUser adds a new user with the given password.
Name string `json:"name"` func (a *Auth) addUser(u *webUser, password string) (err error) {
Password string `json:"password"`
}
// newSessionToken returns cryptographically secure randomly generated slice of
// bytes of sessionTokenSize length.
//
// TODO(e.burkov): Think about using byte array instead of byte slice.
func newSessionToken() (data []byte, err error) {
randData := make([]byte, sessionTokenSize)
_, err = rand.Read(randData)
if err != nil {
return nil, err
}
return randData, nil
}
// newCookie creates a new authentication cookie.
func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
rateLimiter := a.raleLimiter
u, ok := a.findUser(req.Name, req.Password)
if !ok {
if rateLimiter != nil {
rateLimiter.inc(addr)
}
return nil, errors.Error("invalid username or password")
}
if rateLimiter != nil {
rateLimiter.remove(addr)
}
sess, err := newSessionToken()
if err != nil {
return nil, fmt.Errorf("generating token: %w", err)
}
now := time.Now().UTC()
a.addSession(sess, &session{
userName: u.Name,
expire: uint32(now.Unix()) + a.sessionTTL,
})
return &http.Cookie{
Name: sessionCookieName,
Value: hex.EncodeToString(sess),
Path: "/",
Expires: now.Add(cookieTTL),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
}
// realIP extracts the real IP address of the client from an HTTP request using
// the known HTTP headers.
//
// TODO(a.garipov): Currently, this is basically a copy of a similar function in
// module dnsproxy. This should really become a part of module golibs and be
// replaced both here and there. Or be replaced in both places by
// a well-maintained third-party module.
//
// TODO(a.garipov): Support header Forwarded from RFC 7329.
func realIP(r *http.Request) (ip net.IP, err error) {
proxyHeaders := []string{
httphdr.CFConnectingIP,
httphdr.TrueClientIP,
httphdr.XRealIP,
}
for _, h := range proxyHeaders {
v := r.Header.Get(h)
ip = net.ParseIP(v)
if ip != nil {
return ip, nil
}
}
// If none of the above yielded any results, get the leftmost IP address
// from the X-Forwarded-For header.
s := r.Header.Get(httphdr.XForwardedFor)
ipStrs := strings.SplitN(s, ", ", 2)
ip = net.ParseIP(ipStrs[0])
if ip != nil {
return ip, nil
}
// When everything else fails, just return the remote address as understood
// by the stdlib.
ipStr, err := netutil.SplitHost(r.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("getting ip from client addr: %w", err)
}
return net.ParseIP(ipStr), nil
}
// writeErrorWithIP is like [aghhttp.Error], but includes the remote IP address
// when it writes to the log.
func writeErrorWithIP(
r *http.Request,
w http.ResponseWriter,
code int,
remoteIP string,
format string,
args ...any,
) {
text := fmt.Sprintf(format, args...)
log.Error("%s %s %s: from ip %s: %s", r.Method, r.Host, r.URL, remoteIP, text)
http.Error(w, text, code)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
req := loginJSON{}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "json decode: %s", err)
return
}
var remoteIP string
// realIP cannot be used here without taking TrustedProxies into account due
// to security issues.
//
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
//
// TODO(e.burkov): Use realIP when the issue will be fixed.
if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil {
writeErrorWithIP(
r,
w,
http.StatusBadRequest,
r.RemoteAddr,
"auth: getting remote address: %s",
err,
)
return
}
if rateLimiter := Context.auth.raleLimiter; rateLimiter != nil {
if left := rateLimiter.check(remoteIP); left > 0 {
w.Header().Set(httphdr.RetryAfter, strconv.Itoa(int(left.Seconds())))
writeErrorWithIP(
r,
w,
http.StatusTooManyRequests,
remoteIP,
"auth: blocked for %s",
left,
)
return
}
}
cookie, err := Context.auth.newCookie(req, remoteIP)
if err != nil {
writeErrorWithIP(r, w, http.StatusForbidden, remoteIP, "%s", err)
return
}
// Use realIP here, since this IP address is only used for logging.
ip, err := realIP(r)
if err != nil {
log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err)
}
log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip)
http.SetCookie(w, cookie)
h := w.Header()
h.Set(httphdr.CacheControl, "no-store, no-cache, must-revalidate, proxy-revalidate")
h.Set(httphdr.Pragma, "no-cache")
h.Set(httphdr.Expires, "0")
aghhttp.OK(w)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
respHdr := w.Header()
c, err := r.Cookie(sessionCookieName)
if err != nil {
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
// The user is already logged out.
respHdr.Set(httphdr.Location, "/login.html")
w.WriteHeader(http.StatusFound)
return
}
Context.auth.RemoveSession(c.Value)
c = &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
respHdr.Set(httphdr.Location, "/login.html")
respHdr.Set(httphdr.SetCookie, c.String())
w.WriteHeader(http.StatusFound)
}
// RegisterAuthHandlers - register handlers
func RegisterAuthHandlers() {
Context.mux.Handle("/control/login", postInstallHandler(ensureHandler(http.MethodPost, handleLogin)))
httpRegister(http.MethodGet, "/control/logout", handleLogout)
}
// optionalAuthThird return true if user should authenticate first.
func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
if glProcessCookie(r) {
log.Debug("auth: authentication is handled by GL-Inet submodule")
return false
}
// redirect to login page if not authenticated
isAuthenticated := false
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
// Check Basic authentication.
user, pass, hasBasic := r.BasicAuth()
if hasBasic {
_, isAuthenticated = Context.auth.findUser(user, pass)
if !isAuthenticated {
log.Info("auth: invalid Basic Authorization value")
}
}
} else {
res := Context.auth.checkSession(cookie.Value)
isAuthenticated = res == checkSessionOK
if !isAuthenticated {
log.Debug("auth: invalid cookie value: %s", cookie)
}
}
if isAuthenticated {
return false
}
if p := r.URL.Path; p == "/" || p == "/index.html" {
if glProcessRedirect(w, r) {
log.Debug("auth: redirected to login page by GL-Inet submodule")
} else {
log.Debug("auth: redirected to login page")
http.Redirect(w, r, "login.html", http.StatusFound)
}
} else {
log.Debug("auth: responded with forbidden to %s %s", r.Method, p)
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("Forbidden"))
}
return true
}
// TODO(a.garipov): Use [http.Handler] consistently everywhere throughout the
// project.
func optionalAuth(
h func(http.ResponseWriter, *http.Request),
) (wrapped func(http.ResponseWriter, *http.Request)) {
return func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
authRequired := Context.auth != nil && Context.auth.AuthRequired()
if p == "/login.html" {
cookie, err := r.Cookie(sessionCookieName)
if authRequired && err == nil {
// Redirect to the dashboard if already authenticated.
res := Context.auth.checkSession(cookie.Value)
if res == checkSessionOK {
http.Redirect(w, r, "", http.StatusFound)
return
}
log.Debug("auth: invalid cookie value: %s", cookie)
}
} else if isPublicResource(p) {
// Process as usual, no additional auth requirements.
} else if authRequired {
if optionalAuthThird(w, r) {
return
}
}
h(w, r)
}
}
// isPublicResource returns true if p is a path to a public resource.
func isPublicResource(p string) (ok bool) {
isAsset, err := path.Match("/assets/*", p)
if err != nil {
// The only error that is returned from path.Match is
// [path.ErrBadPattern]. This is a programmer error.
panic(fmt.Errorf("bad asset pattern: %w", err))
}
isLogin, err := path.Match("/login.*", p)
if err != nil {
// Same as above.
panic(fmt.Errorf("bad login pattern: %w", err))
}
return isAsset || isLogin
}
type authHandler struct {
handler http.Handler
}
func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
optionalAuth(a.handler.ServeHTTP)(w, r)
}
func optionalAuthHandler(handler http.Handler) http.Handler {
return &authHandler{handler}
}
// Add adds a new user with the given password.
func (a *Auth) Add(u *webUser, password string) (err error) {
if len(password) == 0 { if len(password) == 0 {
return errors.Error("empty password") return errors.Error("empty password")
} }
@ -715,22 +368,40 @@ func (a *Auth) getCurrentUser(r *http.Request) (u webUser) {
return webUser{} return webUser{}
} }
// GetUsers - get users // usersList returns a copy of a users list.
func (a *Auth) GetUsers() []webUser { func (a *Auth) usersList() (users []webUser) {
a.lock.Lock() a.lock.Lock()
users := a.users defer a.lock.Unlock()
a.lock.Unlock()
users = make([]webUser, len(a.users))
copy(users, a.users)
return users return users
} }
// AuthRequired - if authentication is required // authRequired returns true if a authentication is required.
func (a *Auth) AuthRequired() bool { func (a *Auth) authRequired() bool {
if GLMode { if GLMode {
return true return true
} }
a.lock.Lock() a.lock.Lock()
r := (len(a.users) != 0) defer a.lock.Unlock()
a.lock.Unlock()
return r return len(a.users) != 0
}
// newSessionToken returns cryptographically secure randomly generated slice of
// bytes of sessionTokenSize length.
//
// TODO(e.burkov): Think about using byte array instead of byte slice.
func newSessionToken() (data []byte, err error) {
randData := make([]byte, sessionTokenSize)
_, err = rand.Read(randData)
if err != nil {
return nil, err
}
return randData, nil
} }

View File

@ -0,0 +1,89 @@
package home
import (
"bytes"
"crypto/rand"
"encoding/hex"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSessionToken(t *testing.T) {
// Successful case.
token, err := newSessionToken()
require.NoError(t, err)
assert.Len(t, token, sessionTokenSize)
// Break the rand.Reader.
prevReader := rand.Reader
t.Cleanup(func() { rand.Reader = prevReader })
rand.Reader = &bytes.Buffer{}
// Unsuccessful case.
token, err = newSessionToken()
require.Error(t, err)
assert.Empty(t, token)
}
func TestAuth(t *testing.T) {
dir := t.TempDir()
fn := filepath.Join(dir, "sessions.db")
users := []webUser{{
Name: "name",
PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
}}
a := InitAuth(fn, nil, 60, nil)
s := session{}
user := webUser{Name: "name"}
err := a.addUser(&user, "password")
require.NoError(t, err)
assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
a.removeSession("notfound")
sess, err := newSessionToken()
require.NoError(t, err)
sessStr := hex.EncodeToString(sess)
now := time.Now().UTC().Unix()
// check expiration
s.expire = uint32(now)
a.addSession(sess, &s)
assert.Equal(t, checkSessionExpired, a.checkSession(sessStr))
// add session with TTL = 2 sec
s = session{}
s.expire = uint32(time.Now().UTC().Unix() + 2)
a.addSession(sess, &s)
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
a.Close()
// load saved session
a = InitAuth(fn, users, 60, nil)
// the session is still alive
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
// reset our expiration time because checkSession() has just updated it
s.expire = uint32(time.Now().UTC().Unix() + 2)
a.storeSession(sess, &s)
a.Close()
u, ok := a.findUser("name", "password")
assert.True(t, ok)
assert.NotEmpty(t, u.Name)
time.Sleep(3 * time.Second)
// load and remove expired sessions
a = InitAuth(fn, users, 60, nil)
assert.Equal(t, checkSessionNotFound, a.checkSession(sessStr))
a.Close()
}

352
internal/home/authhttp.go Normal file
View File

@ -0,0 +1,352 @@
package home
import (
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"path"
"strconv"
"strings"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/httphdr"
"github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/netutil"
"github.com/AdguardTeam/golibs/timeutil"
)
// cookieTTL is the time-to-live of the session cookie.
const cookieTTL = 365 * timeutil.Day
// sessionCookieName is the name of the session cookie.
const sessionCookieName = "agh_session"
// loginJSON is the JSON structure for authentication.
type loginJSON struct {
Name string `json:"name"`
Password string `json:"password"`
}
// newCookie creates a new authentication cookie.
func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
rateLimiter := a.rateLimiter
u, ok := a.findUser(req.Name, req.Password)
if !ok {
if rateLimiter != nil {
rateLimiter.inc(addr)
}
return nil, errors.Error("invalid username or password")
}
if rateLimiter != nil {
rateLimiter.remove(addr)
}
sess, err := newSessionToken()
if err != nil {
return nil, fmt.Errorf("generating token: %w", err)
}
now := time.Now().UTC()
a.addSession(sess, &session{
userName: u.Name,
expire: uint32(now.Unix()) + a.sessionTTL,
})
return &http.Cookie{
Name: sessionCookieName,
Value: hex.EncodeToString(sess),
Path: "/",
Expires: now.Add(cookieTTL),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
}
// realIP extracts the real IP address of the client from an HTTP request using
// the known HTTP headers.
//
// TODO(a.garipov): Currently, this is basically a copy of a similar function in
// module dnsproxy. This should really become a part of module golibs and be
// replaced both here and there. Or be replaced in both places by
// a well-maintained third-party module.
//
// TODO(a.garipov): Support header Forwarded from RFC 7329.
func realIP(r *http.Request) (ip net.IP, err error) {
proxyHeaders := []string{
httphdr.CFConnectingIP,
httphdr.TrueClientIP,
httphdr.XRealIP,
}
for _, h := range proxyHeaders {
v := r.Header.Get(h)
ip = net.ParseIP(v)
if ip != nil {
return ip, nil
}
}
// If none of the above yielded any results, get the leftmost IP address
// from the X-Forwarded-For header.
s := r.Header.Get(httphdr.XForwardedFor)
ipStrs := strings.SplitN(s, ", ", 2)
ip = net.ParseIP(ipStrs[0])
if ip != nil {
return ip, nil
}
// When everything else fails, just return the remote address as understood
// by the stdlib.
ipStr, err := netutil.SplitHost(r.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("getting ip from client addr: %w", err)
}
return net.ParseIP(ipStr), nil
}
// writeErrorWithIP is like [aghhttp.Error], but includes the remote IP address
// when it writes to the log.
func writeErrorWithIP(
r *http.Request,
w http.ResponseWriter,
code int,
remoteIP string,
format string,
args ...any,
) {
text := fmt.Sprintf(format, args...)
log.Error("%s %s %s: from ip %s: %s", r.Method, r.Host, r.URL, remoteIP, text)
http.Error(w, text, code)
}
// handleLogin is the handler for the POST /control/login HTTP API.
func handleLogin(w http.ResponseWriter, r *http.Request) {
req := loginJSON{}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "json decode: %s", err)
return
}
var remoteIP string
// realIP cannot be used here without taking TrustedProxies into account due
// to security issues.
//
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
//
// TODO(e.burkov): Use realIP when the issue will be fixed.
if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil {
writeErrorWithIP(
r,
w,
http.StatusBadRequest,
r.RemoteAddr,
"auth: getting remote address: %s",
err,
)
return
}
if rateLimiter := Context.auth.rateLimiter; rateLimiter != nil {
if left := rateLimiter.check(remoteIP); left > 0 {
w.Header().Set(httphdr.RetryAfter, strconv.Itoa(int(left.Seconds())))
writeErrorWithIP(
r,
w,
http.StatusTooManyRequests,
remoteIP,
"auth: blocked for %s",
left,
)
return
}
}
cookie, err := Context.auth.newCookie(req, remoteIP)
if err != nil {
writeErrorWithIP(r, w, http.StatusForbidden, remoteIP, "%s", err)
return
}
// Use realIP here, since this IP address is only used for logging.
ip, err := realIP(r)
if err != nil {
log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err)
}
log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip)
http.SetCookie(w, cookie)
h := w.Header()
h.Set(httphdr.CacheControl, "no-store, no-cache, must-revalidate, proxy-revalidate")
h.Set(httphdr.Pragma, "no-cache")
h.Set(httphdr.Expires, "0")
aghhttp.OK(w)
}
// handleLogout is the handler for the GET /control/logout HTTP API.
func handleLogout(w http.ResponseWriter, r *http.Request) {
respHdr := w.Header()
c, err := r.Cookie(sessionCookieName)
if err != nil {
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
// The user is already logged out.
respHdr.Set(httphdr.Location, "/login.html")
w.WriteHeader(http.StatusFound)
return
}
Context.auth.removeSession(c.Value)
c = &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
respHdr.Set(httphdr.Location, "/login.html")
respHdr.Set(httphdr.SetCookie, c.String())
w.WriteHeader(http.StatusFound)
}
// RegisterAuthHandlers - register handlers
func RegisterAuthHandlers() {
Context.mux.Handle("/control/login", postInstallHandler(ensureHandler(http.MethodPost, handleLogin)))
httpRegister(http.MethodGet, "/control/logout", handleLogout)
}
// optionalAuthThird returns true if a user should authenticate first.
func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
pref := fmt.Sprintf("auth: raddr %s", r.RemoteAddr)
if glProcessCookie(r) {
log.Debug("%s: authentication is handled by gl-inet submodule", pref)
return false
}
// redirect to login page if not authenticated
isAuthenticated := false
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
// Check Basic authentication.
user, pass, hasBasic := r.BasicAuth()
if hasBasic {
_, isAuthenticated = Context.auth.findUser(user, pass)
if !isAuthenticated {
log.Info("%s: invalid basic authorization value", pref)
}
}
} else {
res := Context.auth.checkSession(cookie.Value)
isAuthenticated = res == checkSessionOK
if !isAuthenticated {
log.Debug("%s: invalid cookie value: %q", pref, cookie)
}
}
if isAuthenticated {
return false
}
if p := r.URL.Path; p == "/" || p == "/index.html" {
if glProcessRedirect(w, r) {
log.Debug("%s: redirected to login page by gl-inet submodule", pref)
} else {
log.Debug("%s: redirected to login page", pref)
http.Redirect(w, r, "login.html", http.StatusFound)
}
} else {
log.Debug("%s: responded with forbidden to %s %s", pref, r.Method, p)
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("Forbidden"))
}
return true
}
// TODO(a.garipov): Use [http.Handler] consistently everywhere throughout the
// project.
func optionalAuth(
h func(http.ResponseWriter, *http.Request),
) (wrapped func(http.ResponseWriter, *http.Request)) {
return func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
authRequired := Context.auth != nil && Context.auth.authRequired()
if p == "/login.html" {
cookie, err := r.Cookie(sessionCookieName)
if authRequired && err == nil {
// Redirect to the dashboard if already authenticated.
res := Context.auth.checkSession(cookie.Value)
if res == checkSessionOK {
http.Redirect(w, r, "", http.StatusFound)
return
}
log.Debug("auth: raddr %s: invalid cookie value: %q", r.RemoteAddr, cookie)
}
} else if isPublicResource(p) {
// Process as usual, no additional auth requirements.
} else if authRequired {
if optionalAuthThird(w, r) {
return
}
}
h(w, r)
}
}
// isPublicResource returns true if p is a path to a public resource.
func isPublicResource(p string) (ok bool) {
isAsset, err := path.Match("/assets/*", p)
if err != nil {
// The only error that is returned from path.Match is
// [path.ErrBadPattern]. This is a programmer error.
panic(fmt.Errorf("bad asset pattern: %w", err))
}
isLogin, err := path.Match("/login.*", p)
if err != nil {
// Same as above.
panic(fmt.Errorf("bad login pattern: %w", err))
}
return isAsset || isLogin
}
// authHandler is a helper structure that implements [http.Handler].
type authHandler struct {
handler http.Handler
}
// ServeHTTP implements the [http.Handler] interface for *authHandler.
func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
optionalAuth(a.handler.ServeHTTP)(w, r)
}
// optionalAuthHandler returns a authentication handler.
func optionalAuthHandler(handler http.Handler) http.Handler {
return &authHandler{handler}
}

View File

@ -1,16 +1,12 @@
package home package home
import ( import (
"bytes"
"crypto/rand"
"encoding/hex"
"net" "net"
"net/http" "net/http"
"net/textproto" "net/textproto"
"net/url" "net/url"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/AdguardTeam/golibs/httphdr" "github.com/AdguardTeam/golibs/httphdr"
"github.com/AdguardTeam/golibs/testutil" "github.com/AdguardTeam/golibs/testutil"
@ -18,82 +14,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestNewSessionToken(t *testing.T) {
// Successful case.
token, err := newSessionToken()
require.NoError(t, err)
assert.Len(t, token, sessionTokenSize)
// Break the rand.Reader.
prevReader := rand.Reader
t.Cleanup(func() { rand.Reader = prevReader })
rand.Reader = &bytes.Buffer{}
// Unsuccessful case.
token, err = newSessionToken()
require.Error(t, err)
assert.Empty(t, token)
}
func TestAuth(t *testing.T) {
dir := t.TempDir()
fn := filepath.Join(dir, "sessions.db")
users := []webUser{{
Name: "name",
PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
}}
a := InitAuth(fn, nil, 60, nil)
s := session{}
user := webUser{Name: "name"}
err := a.Add(&user, "password")
require.NoError(t, err)
assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
a.RemoveSession("notfound")
sess, err := newSessionToken()
require.NoError(t, err)
sessStr := hex.EncodeToString(sess)
now := time.Now().UTC().Unix()
// check expiration
s.expire = uint32(now)
a.addSession(sess, &s)
assert.Equal(t, checkSessionExpired, a.checkSession(sessStr))
// add session with TTL = 2 sec
s = session{}
s.expire = uint32(time.Now().UTC().Unix() + 2)
a.addSession(sess, &s)
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
a.Close()
// load saved session
a = InitAuth(fn, users, 60, nil)
// the session is still alive
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
// reset our expiration time because checkSession() has just updated it
s.expire = uint32(time.Now().UTC().Unix() + 2)
a.storeSession(sess, &s)
a.Close()
u, ok := a.findUser("name", "password")
assert.True(t, ok)
assert.NotEmpty(t, u.Name)
time.Sleep(3 * time.Second)
// load and remove expired sessions
a = InitAuth(fn, users, 60, nil)
assert.Equal(t, checkSessionNotFound, a.checkSession(sessStr))
a.Close()
}
// implements http.ResponseWriter // implements http.ResponseWriter
type testResponseWriter struct { type testResponseWriter struct {
hdr http.Header hdr http.Header

View File

@ -587,7 +587,7 @@ func (c *configuration) write() (err error) {
defer c.Unlock() defer c.Unlock()
if Context.auth != nil { if Context.auth != nil {
config.Users = Context.auth.GetUsers() config.Users = Context.auth.usersList()
} }
if Context.tls != nil { if Context.tls != nil {

View File

@ -420,7 +420,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request
u := &webUser{ u := &webUser{
Name: req.Username, Name: req.Username,
} }
err = Context.auth.Add(u, req.Password) err = Context.auth.addUser(u, req.Password)
if err != nil { if err != nil {
Context.firstRun = true Context.firstRun = true
copyInstallSettings(config, curConfig) copyInstallSettings(config, curConfig)