From bd3774ed69622f69e5c09b35132a4d326619627a Mon Sep 17 00:00:00 2001 From: Stanislav Chzhen Date: Wed, 9 Jul 2025 17:28:45 +0300 Subject: [PATCH] Pull request 2434: AGDNS-2743-auth-mw-usage Merge in DNS/adguard-home from AGDNS-2743-auth-mw-usage to master Squashed commit of the following: commit 9e3054a42f1b04a00c207810a8dc08696e44c466 Merge: 610c6fc45 1317e296f Author: Stanislav Chzhen Date: Wed Jul 9 17:09:26 2025 +0300 Merge branch 'master' into AGDNS-2743-auth-mw-usage commit 610c6fc45d2d8848e9b89afb0037b98d83106afa Author: Stanislav Chzhen Date: Tue Jul 8 19:03:44 2025 +0300 home: imp docs commit 4633c8991ca77ec182b17a299d9f7e75e145f2ee Author: Stanislav Chzhen Date: Thu Jul 3 21:01:54 2025 +0300 home: add tests commit 586b714dafc342670b441fb29e3c7c8fde83ba4c Author: Stanislav Chzhen Date: Thu Jul 3 14:25:28 2025 +0300 home: fix first run commit 7c2e5d41f0feaae7b50ea0e69cf2767ba17d59df Author: Stanislav Chzhen Date: Wed Jul 2 23:52:16 2025 +0300 home: imp code commit b90031495ae3b6fab7361528fb3a3013139a4965 Author: Stanislav Chzhen Date: Wed Jul 2 14:06:42 2025 +0300 home: rm unused commit 90cb29f2daac7b825b03857414bb04a692736e22 Author: Stanislav Chzhen Date: Wed Jul 2 13:55:45 2025 +0300 home: auth mw usage --- internal/home/auth.go | 481 ++++++---------------- internal/home/auth_internal_test.go | 79 ++-- internal/home/authglinet.go | 125 ++---- internal/home/authglinet_internal_test.go | 26 +- internal/home/authhttp.go | 378 +++++++++-------- internal/home/authhttp_internal_test.go | 287 +++++-------- internal/home/authratelimiter.go | 42 +- internal/home/config.go | 8 +- internal/home/control.go | 14 +- internal/home/controlinstall.go | 14 +- internal/home/dns.go | 2 +- internal/home/home.go | 76 ++-- internal/home/profilehttp.go | 16 +- internal/home/tls_internal_test.go | 13 +- internal/home/web.go | 28 +- 15 files changed, 646 insertions(+), 943 deletions(-) diff --git a/internal/home/auth.go b/internal/home/auth.go index c5a6d129..f0e074c0 100644 --- a/internal/home/auth.go +++ b/internal/home/auth.go @@ -1,317 +1,162 @@ package home import ( - "crypto/rand" - "encoding/binary" - "encoding/hex" + "context" "fmt" - "net/http" - "sync" + "log/slog" "time" - "github.com/AdguardTeam/AdGuardHome/internal/aghos" + "github.com/AdguardTeam/AdGuardHome/internal/aghuser" "github.com/AdguardTeam/golibs/errors" - "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/golibs/logutil/slogutil" "github.com/AdguardTeam/golibs/netutil" - "go.etcd.io/bbolt" + "github.com/AdguardTeam/golibs/netutil/httputil" + "github.com/AdguardTeam/golibs/timeutil" "golang.org/x/crypto/bcrypt" ) -// sessionTokenSize is the length of session token in bytes. -const sessionTokenSize = 16 - -type session struct { - userName string - // expire is the expiration time, in seconds. - expire uint32 -} - -func (s *session) serialize() []byte { - const ( - expireLen = 4 - nameLen = 2 - ) - data := make([]byte, expireLen+nameLen+len(s.userName)) - binary.BigEndian.PutUint32(data[0:4], s.expire) - binary.BigEndian.PutUint16(data[4:6], uint16(len(s.userName))) - copy(data[6:], []byte(s.userName)) - return data -} - -func (s *session) deserialize(data []byte) bool { - if len(data) < 4+2 { - return false - } - s.expire = binary.BigEndian.Uint32(data[0:4]) - nameLen := binary.BigEndian.Uint16(data[4:6]) - data = data[6:] - - if len(data) < int(nameLen) { - return false - } - s.userName = string(data) - return true -} - -// Auth is the global authentication object. -type Auth struct { - trustedProxies netutil.SubnetSet - db *bbolt.DB - rateLimiter *authRateLimiter - sessions map[string]*session - users []webUser - lock sync.Mutex - sessionTTL uint32 -} +// sessionsDBName is the name of the file where session data is stored. +const sessionsDBName = "sessions.db" // webUser represents a user of the Web UI. // // TODO(s.chzhen): Improve naming. type webUser struct { - Name string `yaml:"name"` + // Name represents the login name of the web user. + Name string `yaml:"name"` + + // PasswordHash is the hashed representation of the web user password. PasswordHash string `yaml:"password"` + + // UserID is the unique identifier of the web user. + UserID aghuser.UserID `yaml:"-"` } -// InitAuth initializes the global authentication object. -func InitAuth( - dbFilename string, - users []webUser, - sessionTTL uint32, - rateLimiter *authRateLimiter, - trustedProxies netutil.SubnetSet, -) (a *Auth) { - log.Info("Initializing auth module: %s", dbFilename) - - a = &Auth{ - sessionTTL: sessionTTL, - rateLimiter: rateLimiter, - sessions: make(map[string]*session), - users: users, - trustedProxies: trustedProxies, +// toUser returns the new properly initialized *aghuser.User using stored +// properties. It panics if there is an error generating the user ID. +func (wu *webUser) toUser() (u *aghuser.User) { + uid := wu.UserID + if uid == (aghuser.UserID{}) { + uid = aghuser.MustNewUserID() } - var err error - a.db, err = bbolt.Open(dbFilename, aghos.DefaultPermFile, nil) - if err != nil { - log.Error("auth: open DB: %s: %s", dbFilename, err) - if err.Error() == "invalid argument" { - log.Error("AdGuard Home cannot be initialized due to an incompatible file system.\nPlease read the explanation here: https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#limitations") - } - - return nil + return &aghuser.User{ + Password: aghuser.NewDefaultPassword(wu.PasswordHash), + Login: aghuser.Login(wu.Name), + ID: uid, } - a.loadSessions() - log.Info("auth: initialized. users:%d sessions:%d", len(a.users), len(a.sessions)) - - return a } -// Close closes the authentication database. -func (a *Auth) Close() { - _ = a.db.Close() +// authConfig is the configuration structure for [auth]. +type authConfig struct { + // baseLogger is used for creating other loggers. It must not be nil. + baseLogger *slog.Logger + + // rateLimiter manages the rate limiting for login attempts. It must not be + // nil. + rateLimiter loginRaateLimiter + + // trustedProxies is a set of subnets considered as trusted. + trustedProxies netutil.SubnetSet + + // dbFilename is the name of the file where session data is stored. It must + // not be empty. + dbFilename string + + // users contains web user information from the configuration file. + users []webUser + + // sessionTTL is the TTL (Time To Live) for web user sessions. + sessionTTL time.Duration + + // isGLiNet indicates whether GLiNet mode is enabled. + isGLiNet bool } -func bucketName() []byte { - return []byte("sessions-2") +// auth stores web user information and handles authentication. +type auth struct { + logger *slog.Logger + rateLimiter loginRaateLimiter + trustedProxies netutil.SubnetSet + sessions aghuser.SessionStorage + users aghuser.DB + isGLiNet bool } -// loadSessions loads sessions from the database file and removes expired -// sessions. -func (a *Auth) loadSessions() { - tx, err := a.db.Begin(true) - if err != nil { - log.Error("auth: bbolt.Begin: %s", err) - - return - } - defer func() { - _ = tx.Rollback() - }() - - bkt := tx.Bucket(bucketName()) - if bkt == nil { - return - } - - removed := 0 - - if tx.Bucket([]byte("sessions")) != nil { - _ = tx.DeleteBucket([]byte("sessions")) - removed = 1 - } - - now := uint32(time.Now().UTC().Unix()) - forEach := func(k, v []byte) error { - s := session{} - if !s.deserialize(v) || s.expire <= now { - err = bkt.Delete(k) - if err != nil { - log.Error("auth: bbolt.Delete: %s", err) - } else { - removed++ - } - - return nil - } - - a.sessions[hex.EncodeToString(k)] = &s - return nil - } - _ = bkt.ForEach(forEach) - if removed != 0 { - err = tx.Commit() +// newAuth returns the new properly initialized *auth. +func newAuth(ctx context.Context, conf *authConfig) (a *auth, err error) { + userDB := aghuser.NewDefaultDB() + for i, u := range conf.users { + err = userDB.Create(ctx, u.toUser()) if err != nil { - log.Error("bolt.Commit(): %s", err) + return nil, fmt.Errorf("users: at index %d: %w", i, err) } } - log.Debug("auth: loaded %d sessions from DB (removed %d expired)", len(a.sessions), removed) + s, err := aghuser.NewDefaultSessionStorage(ctx, &aghuser.DefaultSessionStorageConfig{ + Logger: conf.baseLogger.With(slogutil.KeyPrefix, "session_storage"), + Clock: timeutil.SystemClock{}, + UserDB: userDB, + DBPath: conf.dbFilename, + SessionTTL: conf.sessionTTL, + }) + if err != nil { + return nil, fmt.Errorf("creating session storage: %w", err) + } + + return &auth{ + logger: conf.baseLogger.With(slogutil.KeyPrefix, "auth"), + rateLimiter: conf.rateLimiter, + trustedProxies: conf.trustedProxies, + sessions: s, + users: userDB, + isGLiNet: conf.isGLiNet, + }, nil } -// addSession adds a new session to the list of sessions and saves it in the -// database file. -func (a *Auth) addSession(data []byte, s *session) { - name := hex.EncodeToString(data) - a.lock.Lock() - a.sessions[name] = s - a.lock.Unlock() - if a.storeSession(data, s) { - log.Debug("auth: created session %s: expire=%d", name, s.expire) +// middleware returns authentication middleware. +func (a *auth) middleware() (mw httputil.Middleware) { + if a.isGLiNet { + return newAuthMiddlewareGLiNet(&authMiddlewareGLiNetConfig{ + logger: a.logger, + clock: timeutil.SystemClock{}, + tokenFilePrefix: glFilePrefix, + ttl: glTokenTimeout, + maxTokenSize: MaxFileSize, + }) } + + return newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{ + logger: a.logger, + rateLimiter: a.rateLimiter, + trustedProxies: a.trustedProxies, + sessions: a.sessions, + users: a.users, + }) } -// storeSession saves a session in the database file. -func (a *Auth) storeSession(data []byte, s *session) bool { - tx, err := a.db.Begin(true) +// usersList returns a copy of a users list. +func (a *auth) usersList(ctx context.Context) (webUsers []webUser) { + users, err := a.users.All(ctx) if err != nil { - log.Error("auth: bbolt.Begin: %s", err) - - return false - } - defer func() { - _ = tx.Rollback() - }() - - bkt, err := tx.CreateBucketIfNotExists(bucketName()) - if err != nil { - log.Error("auth: bbolt.CreateBucketIfNotExists: %s", err) - - return false + // Should not happen. + panic(err) } - err = bkt.Put(data, s.serialize()) - if err != nil { - log.Error("auth: bbolt.Put: %s", err) - - return false + webUsers = make([]webUser, 0, len(users)) + for _, u := range users { + webUsers = append(webUsers, webUser{ + Name: string(u.Login), + PasswordHash: string(u.Password.Hash()), + UserID: u.ID, + }) } - err = tx.Commit() - if err != nil { - log.Error("auth: bbolt.Commit: %s", err) - - return false - } - - return true + return webUsers } -// removeSessionFromFile removes a stored session from the DB file on disk. -func (a *Auth) removeSessionFromFile(sess []byte) { - tx, err := a.db.Begin(true) - if err != nil { - log.Error("auth: bbolt.Begin: %s", err) - - return - } - - defer func() { - _ = tx.Rollback() - }() - - bkt := tx.Bucket(bucketName()) - if bkt == nil { - log.Error("auth: bbolt.Bucket") - - return - } - - err = bkt.Delete(sess) - if err != nil { - log.Error("auth: bbolt.Put: %s", err) - - return - } - - err = tx.Commit() - if err != nil { - log.Error("auth: bbolt.Commit: %s", err) - - return - } - - log.Debug("auth: removed session from DB") -} - -// checkSessionResult is the result of checking a session. -type checkSessionResult int - -// checkSessionResult constants. -const ( - checkSessionOK checkSessionResult = 0 - checkSessionNotFound checkSessionResult = -1 - checkSessionExpired checkSessionResult = 1 -) - -// checkSession checks if the session is valid. -func (a *Auth) checkSession(sess string) (res checkSessionResult) { - now := uint32(time.Now().UTC().Unix()) - update := false - - a.lock.Lock() - defer a.lock.Unlock() - - s, ok := a.sessions[sess] - if !ok { - return checkSessionNotFound - } - - if s.expire <= now { - delete(a.sessions, sess) - key, _ := hex.DecodeString(sess) - a.removeSessionFromFile(key) - - return checkSessionExpired - } - - newExpire := now + a.sessionTTL - if s.expire/(24*60*60) != newExpire/(24*60*60) { - // update expiration time once a day - update = true - s.expire = newExpire - } - - if update { - key, _ := hex.DecodeString(sess) - if a.storeSession(key, s) { - log.Debug("auth: updated session %s: expire=%d", sess, s.expire) - } - } - - return checkSessionOK -} - -// removeSession removes the session from the active sessions and the disk. -func (a *Auth) removeSession(sess string) { - key, _ := hex.DecodeString(sess) - a.lock.Lock() - delete(a.sessions, sess) - a.lock.Unlock() - a.removeSessionFromFile(key) -} - -// addUser adds a new user with the given password. -func (a *Auth) addUser(u *webUser, password string) (err error) { +// addUser adds a new user with the given password. u must not be nil. +func (a *auth) addUser(ctx context.Context, u *webUser, password string) (err error) { if len(password) == 0 { return errors.Error("empty password") } @@ -323,97 +168,21 @@ func (a *Auth) addUser(u *webUser, password string) (err error) { u.PasswordHash = string(hash) - a.lock.Lock() - defer a.lock.Unlock() + err = a.users.Create(ctx, u.toUser()) + if err != nil { + // Should not happen. + panic(err) + } - a.users = append(a.users, *u) - - log.Debug("auth: added user with login %q", u.Name) + a.logger.DebugContext(ctx, "added user", "login", u.Name) return nil } -// findUser returns a user if there is one. -func (a *Auth) findUser(login, password string) (u webUser, ok bool) { - a.lock.Lock() - defer a.lock.Unlock() - - for _, u = range a.users { - if u.Name == login && - bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { - return u, true - } - } - - return webUser{}, false -} - -// getCurrentUser returns the current user. It returns an empty User if the -// user is not found. -func (a *Auth) getCurrentUser(r *http.Request) (u webUser) { - cookie, err := r.Cookie(sessionCookieName) +// close closes the authentication database. +func (a *auth) close(ctx context.Context) { + err := a.sessions.Close() if err != nil { - // There's no Cookie, check Basic authentication. - user, pass, ok := r.BasicAuth() - if ok { - u, _ = globalContext.auth.findUser(user, pass) - - return u - } - - return webUser{} + a.logger.ErrorContext(ctx, "closing session storage", slogutil.KeyError, err) } - - a.lock.Lock() - defer a.lock.Unlock() - - s, ok := a.sessions[cookie.Value] - if !ok { - return webUser{} - } - - for _, u = range a.users { - if u.Name == s.userName { - return u - } - } - - return webUser{} -} - -// usersList returns a copy of a users list. -func (a *Auth) usersList() (users []webUser) { - a.lock.Lock() - defer a.lock.Unlock() - - users = make([]webUser, len(a.users)) - copy(users, a.users) - - return users -} - -// authRequired returns true if a authentication is required. -func (a *Auth) authRequired() bool { - if GLMode { - return true - } - - a.lock.Lock() - defer a.lock.Unlock() - - 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) { - randData := make([]byte, sessionTokenSize) - - // Since Go 1.24, crypto/rand.Read doesn't return an error and crashes - // unrecoverably instead. - _, _ = rand.Read(randData) - - return randData } diff --git a/internal/home/auth_internal_test.go b/internal/home/auth_internal_test.go index 41aabd87..11af39e8 100644 --- a/internal/home/auth_internal_test.go +++ b/internal/home/auth_internal_test.go @@ -1,69 +1,52 @@ package home import ( - "encoding/hex" "path/filepath" "testing" - "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghuser" + "github.com/AdguardTeam/golibs/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" ) -func TestAuth(t *testing.T) { - dir := t.TempDir() - fn := filepath.Join(dir, "sessions.db") +func TestAuth_UsersList(t *testing.T) { + const ( + userName = "name" + userPassword = "password" + ) - users := []webUser{{ - Name: "name", - PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2", - }} - a := InitAuth(fn, nil, 60, nil, nil) - s := session{} - - user := webUser{Name: "name"} - err := a.addUser(&user, "password") + passwordHash, err := bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost) require.NoError(t, err) - assert.Equal(t, checkSessionNotFound, a.checkSession("notfound")) - a.removeSession("notfound") + sessionsDB := filepath.Join(t.TempDir(), "sessions.db") - sess := newSessionToken() - sessStr := hex.EncodeToString(sess) + user := webUser{ + Name: userName, + PasswordHash: string(passwordHash), + UserID: aghuser.MustNewUserID(), + } - now := time.Now().UTC().Unix() - // check expiration - s.expire = uint32(now) - a.addSession(sess, &s) - assert.Equal(t, checkSessionExpired, a.checkSession(sessStr)) + auth, err := newAuth(testutil.ContextWithTimeout(t, testTimeout), &authConfig{ + baseLogger: testLogger, + rateLimiter: emptyRateLimiter{}, + trustedProxies: nil, + dbFilename: sessionsDB, + users: nil, + sessionTTL: testTimeout, + isGLiNet: false, + }) + require.NoError(t, err) - // 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)) + t.Cleanup(func() { auth.close(testutil.ContextWithTimeout(t, testTimeout)) }) - a.Close() + ctx := testutil.ContextWithTimeout(t, testTimeout) - // load saved session - a = InitAuth(fn, users, 60, nil, nil) + assert.Empty(t, auth.usersList(ctx)) - // 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() + err = auth.addUser(ctx, &user, userPassword) + require.NoError(t, err) - 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, nil) - assert.Equal(t, checkSessionNotFound, a.checkSession(sessStr)) - - a.Close() + assert.Equal(t, []webUser{user}, auth.usersList(ctx)) } diff --git a/internal/home/authglinet.go b/internal/home/authglinet.go index 784d14b1..18c6fc4c 100644 --- a/internal/home/authglinet.go +++ b/internal/home/authglinet.go @@ -1,116 +1,40 @@ package home import ( - "bytes" "context" "encoding/binary" "io" "log/slog" "net" "net/http" + "net/url" "os" "time" - "github.com/AdguardTeam/golibs/ioutil" - "github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/logutil/slogutil" "github.com/AdguardTeam/golibs/netutil/httputil" + "github.com/AdguardTeam/golibs/netutil/urlutil" "github.com/AdguardTeam/golibs/timeutil" ) -// GLMode - enable GL-Inet compatibility mode -var GLMode bool - +// glFilePrefix is the prefix of the filepath where the authentication token is +// stored. Note that it is variable so it can be edited in tests. +// +// TODO(s.chzhen): Make it a constant. var glFilePrefix = "/tmp/gl_token_" const ( - glTokenTimeoutSeconds = 3600 - glCookieName = "Admin-Token" + // glTokenTimeout is the TTL (Time To Live) of the authentication token. + glTokenTimeout = 3600 * time.Second + + // glCookieName is the name of the cookie that stores the authentication + // token. + glCookieName = "Admin-Token" ) -func glProcessRedirect(w http.ResponseWriter, r *http.Request) bool { - if !GLMode { - return false - } - // redirect to gl-inet login - host, _, _ := net.SplitHostPort(r.Host) - url := "http://" + host - log.Debug("Auth: redirecting to %s", url) - http.Redirect(w, r, url, http.StatusFound) - return true -} - -func glProcessCookie(r *http.Request) bool { - if !GLMode { - return false - } - - glCookie, glerr := r.Cookie(glCookieName) - if glerr != nil { - return false - } - - log.Debug("Auth: GL cookie value: %s", glCookie.Value) - if glCheckToken(glCookie.Value) { - return true - } - log.Info("Auth: invalid GL cookie value: %s", glCookie) - return false -} - -func glCheckToken(sess string) bool { - tokenName := glFilePrefix + sess - _, err := os.Stat(tokenName) - if err != nil { - log.Error("os.Stat: %s", err) - return false - } - tokenDate := glGetTokenDate(tokenName) - now := uint32(time.Now().UTC().Unix()) - return now <= (tokenDate + glTokenTimeoutSeconds) -} - // MaxFileSize is a maximum file length in bytes. const MaxFileSize = 1024 * 1024 -func glGetTokenDate(file string) uint32 { - f, err := os.Open(file) - if err != nil { - log.Error("os.Open: %s", err) - - return 0 - } - defer func() { - derr := f.Close() - if derr != nil { - log.Error("glinet: closing file: %s", err) - } - }() - - fileReader := ioutil.LimitReader(f, MaxFileSize) - - var dateToken uint32 - - // This use of ReadAll is now safe, because we limited reader. - bs, err := io.ReadAll(fileReader) - if err != nil { - log.Error("reading token: %s", err) - - return 0 - } - - buf := bytes.NewBuffer(bs) - - err = binary.Read(buf, binary.NativeEndian, &dateToken) - if err != nil { - log.Error("decoding token: %s", err) - - return 0 - } - - return dateToken -} - // authMiddlewareGLiNetConfig is the configuration structure for the GLiNet // authentication middleware. type authMiddlewareGLiNetConfig struct { @@ -166,12 +90,37 @@ var _ httputil.Middleware = (*authMiddlewareGLiNet)(nil) func (mw *authMiddlewareGLiNet) Wrap(h http.Handler) (wrapped http.Handler) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + + path := r.URL.Path + if isPublicResource(path) { + h.ServeHTTP(w, r) + + return + } + if mw.isAuthenticated(ctx, r) { h.ServeHTTP(w, r) return } + if path == "/" || path == "/index.html" { + host := r.Host + + if h, _, err := net.SplitHostPort(r.Host); err == nil { + host = h + } + + u := &url.URL{ + Scheme: urlutil.SchemeHTTP, + Host: host, + } + + http.Redirect(w, r, u.String(), http.StatusFound) + + return + } + w.WriteHeader(http.StatusUnauthorized) }) } diff --git a/internal/home/authglinet_internal_test.go b/internal/home/authglinet_internal_test.go index 258f1652..e0242609 100644 --- a/internal/home/authglinet_internal_test.go +++ b/internal/home/authglinet_internal_test.go @@ -56,7 +56,7 @@ func TestAuthMiddlewareGLiNet(t *testing.T) { }{{ req: httptest.NewRequest(http.MethodGet, "/", nil), name: "no_cookie", - wantCode: http.StatusUnauthorized, + wantCode: http.StatusFound, }, { req: reqValidCookie, name: "valid_cookie", @@ -64,7 +64,7 @@ func TestAuthMiddlewareGLiNet(t *testing.T) { }, { req: reqInvalidCookie, name: "invalid_cookie", - wantCode: http.StatusUnauthorized, + wantCode: http.StatusFound, }} for _, tc := range testCases { @@ -78,25 +78,3 @@ func TestAuthMiddlewareGLiNet(t *testing.T) { }) } } - -func TestAuthGL(t *testing.T) { - dir := t.TempDir() - - GLMode = true - t.Cleanup(func() { GLMode = false }) - glFilePrefix = dir + "/gl_token_" - - data := make([]byte, 4) - binary.NativeEndian.PutUint32(data, 1) - - require.NoError(t, os.WriteFile(glFilePrefix+"test", data, 0o644)) - assert.False(t, glCheckToken("test")) - - data = make([]byte, 4) - binary.NativeEndian.PutUint32(data, uint32(time.Now().UTC().Unix()+60)) - - require.NoError(t, os.WriteFile(glFilePrefix+"test", data, 0o644)) - r, _ := http.NewRequest(http.MethodGet, "http://localhost/", nil) - r.AddCookie(&http.Cookie{Name: glCookieName, Value: "test"}) - assert.True(t, glProcessCookie(r)) -} diff --git a/internal/home/authhttp.go b/internal/home/authhttp.go index 7dcf5030..b8c7b551 100644 --- a/internal/home/authhttp.go +++ b/internal/home/authhttp.go @@ -9,6 +9,7 @@ import ( "net/http" "net/netip" "path" + "slices" "strconv" "strings" "time" @@ -37,40 +38,6 @@ type loginJSON struct { 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 := newSessionToken() - 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. // @@ -130,7 +97,9 @@ func writeErrorWithIP( } // handleLogin is the handler for the POST /control/login HTTP API. -func handleLogin(w http.ResponseWriter, r *http.Request) { +func (web *webAPI) handleLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + req := loginJSON{} err := json.NewDecoder(r.Body).Decode(&req) if err != nil { @@ -140,8 +109,8 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { } var remoteIP string - // realIP cannot be used here without taking TrustedProxies into account due - // to security issues. + // The real IP address of the client [realIP] cannot be used here without + // taking trusted proxies into account due to security issues: // // See https://github.com/AdguardTeam/AdGuardHome/issues/2799. if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil { @@ -157,7 +126,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { return } - if rateLimiter := globalContext.auth.rateLimiter; rateLimiter != nil { + if rateLimiter := web.auth.rateLimiter; rateLimiter != nil { if left := rateLimiter.check(remoteIP); left > 0 { w.Header().Set(httphdr.RetryAfter, strconv.Itoa(int(left.Seconds()))) writeErrorWithIP( @@ -175,13 +144,18 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { ip, err := realIP(r) if err != nil { - log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err) + web.logger.ErrorContext( + ctx, + "getting real ip", + "remote_ip", remoteIP, + slogutil.KeyError, err, + ) } - cookie, err := globalContext.auth.newCookie(req, remoteIP) + cookie, err := newCookie(ctx, web.auth, req, remoteIP) if err != nil { logIP := remoteIP - if globalContext.auth.trustedProxies.Contains(ip.Unmap()) { + if web.auth.trustedProxies.Contains(ip.Unmap()) { logIP = ip.String() } @@ -190,7 +164,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { return } - log.Info("auth: user %q successfully logged in from ip %s", req.Name, ip) + web.logger.InfoContext(ctx, "successful login", "user", req.Name, "ip", ip) http.SetCookie(w, cookie) @@ -202,8 +176,54 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { aghhttp.OK(w) } +// newCookie creates a new authentication cookie. rateLimiter must not be nil. +func newCookie( + ctx context.Context, + auth *auth, + req loginJSON, + addr string, +) (c *http.Cookie, err error) { + user, err := auth.users.ByLogin(ctx, aghuser.Login(req.Name)) + if err != nil { + // Should not happen. + panic(err) + } + + rateLimiter := auth.rateLimiter + if user == nil { + rateLimiter.inc(addr) + + return nil, errInvalidLogin + } + + ok := user.Password.Authenticate(ctx, req.Password) + if !ok { + rateLimiter.inc(addr) + + return nil, errInvalidLogin + } + + rateLimiter.remove(addr) + + sess, err := auth.sessions.New(ctx, user) + if err != nil { + return nil, err + } + + return &http.Cookie{ + Name: sessionCookieName, + Value: hex.EncodeToString(sess.Token[:]), + Path: "/", + Expires: time.Now().Add(cookieTTL), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }, nil +} + // handleLogout is the handler for the GET /control/logout HTTP API. -func handleLogout(w http.ResponseWriter, r *http.Request) { +func (web *webAPI) handleLogout(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + respHdr := w.Header() c, err := r.Cookie(sessionCookieName) if err != nil { @@ -215,7 +235,19 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { return } - globalContext.auth.removeSession(c.Value) + t, err := sessionTokenFromHex(c.Value) + if err != nil { + web.logger.ErrorContext(ctx, "getting token", slogutil.KeyError, err) + + w.WriteHeader(http.StatusUnauthorized) + + return + } + + err = web.auth.sessions.DeleteByToken(ctx, t) + if err != nil { + web.logger.ErrorContext(ctx, "removing session by token", slogutil.KeyError, err) + } c = &http.Cookie{ Name: sessionCookieName, @@ -233,93 +265,12 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { } // RegisterAuthHandlers - register handlers -func RegisterAuthHandlers() { - globalContext.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 = globalContext.auth.findUser(user, pass) - if !isAuthenticated { - log.Info("%s: invalid basic authorization value", pref) - } - } - } else { - res := globalContext.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 := globalContext.auth != nil && globalContext.auth.authRequired() - if p == "/login.html" { - cookie, err := r.Cookie(sessionCookieName) - if authRequired && err == nil { - // Redirect to the dashboard if already authenticated. - res := globalContext.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) - } +func RegisterAuthHandlers(web *webAPI) { + globalContext.mux.Handle( + "/control/login", + postInstallHandler(ensureHandler(http.MethodPost, web.handleLogin)), + ) + httpRegister(http.MethodGet, "/control/logout", web.handleLogout) } // isPublicResource returns true if p is a path to a public resource. @@ -337,22 +288,19 @@ func isPublicResource(p string) (ok bool) { panic(fmt.Errorf("bad login pattern: %w", err)) } - return isAsset || isLogin -} + paths := []string{ + "/dns-query", + "/dns-query/", + "/control/login", + "/apple/doh.mobileconfig", + "/apple/dot.mobileconfig", + "/control/install/get_addresses", + "/control/install/check_config", + "/control/install/configure", + "/install.html", + } -// 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} + return isAsset || isLogin || slices.Contains(paths, p) } const ( @@ -367,6 +315,15 @@ type authMiddlewareDefaultConfig struct { // be nil. logger *slog.Logger + // rateLimiter manages the rate limiting for login attempts. + rateLimiter loginRaateLimiter + + // trustedProxies is a set of subnets considered as trusted. + // + // TODO(s.chzhen): Use it not only to pass it to the middleware but also to + // log the work of the rate limiter. + trustedProxies netutil.SubnetSet + // sessions contains web user sessions. It must not be nil. sessions aghuser.SessionStorage @@ -378,18 +335,22 @@ type authMiddlewareDefaultConfig struct { // for a web client using an authentication cookie or basic auth credentials and // passes it with the context. type authMiddlewareDefault struct { - logger *slog.Logger - sessions aghuser.SessionStorage - users aghuser.DB + logger *slog.Logger + rateLimiter loginRaateLimiter + trustedProxies netutil.SubnetSet + sessions aghuser.SessionStorage + users aghuser.DB } // newAuthMiddlewareDefault returns the new properly initialized // *authMiddlewareDefault. func newAuthMiddlewareDefault(c *authMiddlewareDefaultConfig) (mw *authMiddlewareDefault) { return &authMiddlewareDefault{ - logger: c.logger, - sessions: c.sessions, - users: c.users, + logger: c.logger, + rateLimiter: c.rateLimiter, + trustedProxies: c.trustedProxies, + sessions: c.sessions, + users: c.users, } } @@ -401,49 +362,61 @@ var _ httputil.Middleware = (*authMiddlewareDefault)(nil) func (mw *authMiddlewareDefault) Wrap(h http.Handler) (wrapped http.Handler) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !mw.needsAuthentication(ctx, r) { + + if !mw.needsAuthentication(ctx) { h.ServeHTTP(w, r) return } + path := r.URL.Path u, err := mw.userFromRequest(ctx, r) + if err != nil { + mw.logger.ErrorContext(ctx, "retrieving user from request", slogutil.KeyError, err) + } + if u != nil { + if path == "/login.html" { + http.Redirect(w, r, "/", http.StatusFound) + + return + } + h.ServeHTTP(w, r.WithContext(withWebUser(ctx, u))) return } - if err != nil { - mw.logger.ErrorContext(ctx, "retrieving user from request", slogutil.KeyError, err) + if isPublicResource(path) { + h.ServeHTTP(w, r) + + return + } + + if path == "/" || path == "/index.html" { + http.Redirect(w, r, "login.html", http.StatusFound) + + return } w.WriteHeader(http.StatusUnauthorized) }) } -// needsAuthentication returns true if the current request requires -// authentication. -// -// TODO(s.chzhen): Use the request's path. -func (mw *authMiddlewareDefault) needsAuthentication( - ctx context.Context, - _ *http.Request, -) (ok bool) { +// needsAuthentication returns true if there are stored web users and requests +// should be authenticated first. +func (mw *authMiddlewareDefault) needsAuthentication(ctx context.Context) (ok bool) { users, err := mw.users.All(ctx) if err != nil { // Should not happen. panic(err) } - if len(users) == 0 { - return false - } - - return true + return len(users) != 0 } -// userFromRequest tries to retrieve a user based on the request. +// userFromRequest tries to retrieve a user based on the request. r must not be +// nil. func (mw *authMiddlewareDefault) userFromRequest( ctx context.Context, r *http.Request, @@ -451,25 +424,24 @@ func (mw *authMiddlewareDefault) userFromRequest( defer func() { err = errors.Annotate(err, "getting user from request: %w") }() cookie, err := r.Cookie(sessionCookieName) - if err == http.ErrNoCookie { - return mw.userFromRequestBasicAuth(ctx, r) + if err == nil { + return mw.userFromCookie(ctx, cookie.Value) } - sess, err := hex.DecodeString(cookie.Value) - if err != nil { - return nil, fmt.Errorf("decoding cookie: %w", err) - } + return mw.userFromRequestBasicAuth(ctx, r) +} - l := aghuser.SessionTokenLength - - // TODO(a.garipov): Add validate.Len. - err = validate.InRange("token length", len(sess), l, l) +// userFromCookie tries to retrieve a user based on the provided cookie value. +func (mw *authMiddlewareDefault) userFromCookie( + ctx context.Context, + val string, +) (u *aghuser.User, err error) { + t, err := sessionTokenFromHex(val) if err != nil { // Don't wrap the error because it's informative enough as is. return nil, err } - t := aghuser.SessionToken(sess) s, err := mw.sessions.FindByToken(ctx, t) if err != nil { return nil, fmt.Errorf("searching session by token: %w", err) @@ -487,16 +459,58 @@ func (mw *authMiddlewareDefault) userFromRequest( return u, nil } -// userFromRequestBasicAuth searches for a user using Basic Auth credentials. +// sessionTokenFromHex converts a hexadecimal string into a session token. +func sessionTokenFromHex(val string) (token aghuser.SessionToken, err error) { + sess, err := hex.DecodeString(val) + if err != nil { + return token, fmt.Errorf("decoding value: %w", err) + } + + l := aghuser.SessionTokenLength + + err = validate.Equal("token length", l, len(sess)) + if err != nil { + // Don't wrap the error because it's informative enough as is. + return token, err + } + + return aghuser.SessionToken(sess), nil +} + +// userFromRequestBasicAuth searches for a user using Basic Auth credentials. r +// must not be nil. func (mw *authMiddlewareDefault) userFromRequestBasicAuth( ctx context.Context, r *http.Request, ) (user *aghuser.User, err error) { login, pass, ok := r.BasicAuth() if !ok { - return nil, fmt.Errorf("credentials: %w", errors.ErrNoValue) + return nil, nil } + var remoteIP string + // The real IP address of the client [realIP] cannot be used here without + // taking trusted proxies into account due to security issues: + // + // See https://github.com/AdguardTeam/AdGuardHome/issues/2799. + if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil { + return nil, fmt.Errorf("getting remote address: %w", err) + } + + rateLimiter := mw.rateLimiter + if left := rateLimiter.check(remoteIP); left > 0 { + return nil, fmt.Errorf("login attempt blocked for %s", left) + } + + rateLimiter.inc(remoteIP) + defer func() { + if err != nil { + return + } + + rateLimiter.remove(remoteIP) + }() + user, _ = mw.users.ByLogin(ctx, aghuser.Login(login)) if user == nil { return nil, errInvalidLogin diff --git a/internal/home/authhttp_internal_test.go b/internal/home/authhttp_internal_test.go index f59e455c..93ffa8e8 100644 --- a/internal/home/authhttp_internal_test.go +++ b/internal/home/authhttp_internal_test.go @@ -13,7 +13,6 @@ import ( "net/http/httptest" "net/netip" "net/textproto" - "net/url" "os" "path/filepath" "slices" @@ -166,40 +165,18 @@ func (h *testAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.user, _ = webUserFromContext(r.Context()) } -func TestAuthMiddlewareDefault_firstRun(t *testing.T) { - db := newTestUsersDB() - db.onAll = func(_ context.Context) (users []*aghuser.User, err error) { - return nil, nil - } - - mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{ - logger: testLogger, - sessions: &testSessionStorage{}, - users: db, - }) - - h := &testAuthHandler{} - wrapped := mw.Wrap(h) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - wrapped.ServeHTTP(w, r) - - assert.Equal(t, http.StatusOK, w.Code) - assert.True(t, h.called) -} - func TestAuthMiddlewareDefault(t *testing.T) { t.Parallel() const ( - login aghuser.Login = "user_login" + loginStr = "user_login" + passwordStr = "user_password" - passwordRaw = "user_password" + login = aghuser.Login(loginStr) ) passwordHash, err := bcrypt.GenerateFromPassword( - []byte(passwordRaw), + []byte(passwordStr), bcrypt.DefaultCost, ) require.NoError(t, err) @@ -238,22 +215,14 @@ func TestAuthMiddlewareDefault(t *testing.T) { } mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{ - logger: testLogger, - sessions: ts, - users: usersDB, + logger: testLogger, + rateLimiter: emptyRateLimiter{}, + sessions: ts, + users: usersDB, }) - reqCookie := httptest.NewRequest(http.MethodGet, "/", nil) - reqCookie.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tokenHex}) - - reqInvalidCookie := httptest.NewRequest(http.MethodGet, "/", nil) - reqInvalidCookie.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "invalid_cookie"}) - - reqBasicAuth := httptest.NewRequest(http.MethodGet, "/", nil) - reqBasicAuth.SetBasicAuth(string(login), passwordRaw) - - reqInvalidPassBasicAuth := httptest.NewRequest(http.MethodGet, "/", nil) - reqInvalidPassBasicAuth.SetBasicAuth(string(login), "invalid_password") + cookie := &http.Cookie{Name: sessionCookieName, Value: tokenHex} + invalidCookie := &http.Cookie{Name: sessionCookieName, Value: "123"} testCases := []struct { req *http.Request @@ -263,28 +232,58 @@ func TestAuthMiddlewareDefault(t *testing.T) { }{{ req: httptest.NewRequest(http.MethodGet, "/", nil), wantUser: nil, - name: "no_auth", - wantCode: http.StatusUnauthorized, + name: "no_auth_root", + wantCode: http.StatusFound, }, { - req: reqCookie, + req: httptest.NewRequest(http.MethodGet, "/index.html", nil), + wantUser: nil, + name: "no_auth", + wantCode: http.StatusFound, + }, { + req: authRequest("/", invalidCookie, "", ""), + wantUser: nil, + name: "invalid_auth", + wantCode: http.StatusFound, + }, { + req: authRequest("/", cookie, "", ""), wantUser: user, name: "cookie", wantCode: http.StatusOK, }, { - req: reqBasicAuth, + req: authRequest("/login.html", cookie, "", ""), + wantUser: nil, + name: "redirect", + wantCode: http.StatusFound, + }, { + req: authRequest("/control/profile", cookie, "", ""), + wantUser: user, + name: "protected", + wantCode: http.StatusOK, + }, { + req: authRequest("/control/profile", invalidCookie, "", ""), + wantUser: nil, + name: "no_auth_protected", + wantCode: http.StatusUnauthorized, + }, { + req: httptest.NewRequest(http.MethodGet, "/control/login", nil), + wantUser: nil, + name: "public", + wantCode: http.StatusOK, + }, { + req: authRequest("/", nil, loginStr, passwordStr), wantUser: user, name: "basic_auth", wantCode: http.StatusOK, }, { - req: reqInvalidCookie, + req: authRequest("/", invalidCookie, "", ""), wantUser: nil, name: "invalid_cookie", - wantCode: http.StatusUnauthorized, + wantCode: http.StatusFound, }, { - req: reqInvalidPassBasicAuth, + req: authRequest("/", nil, "invalid", "creds"), wantUser: nil, name: "invalid_basic_auth", - wantCode: http.StatusUnauthorized, + wantCode: http.StatusFound, }} for _, tc := range testCases { @@ -303,6 +302,22 @@ func TestAuthMiddlewareDefault(t *testing.T) { } } +// authRequest is a test helper function that returns a GET request configured +// with the provided credentials and path. +func authRequest(path string, c *http.Cookie, user, pass string) (r *http.Request) { + r = httptest.NewRequest(http.MethodGet, path, nil) + + if c != nil { + r.AddCookie(c) + } + + if user != "" { + r.SetBasicAuth(user, pass) + } + + return r +} + func TestAuth_ServeHTTP_firstRun(t *testing.T) { storeGlobals(t) @@ -312,7 +327,7 @@ func TestAuth_ServeHTTP_firstRun(t *testing.T) { globalContext.mux = mux ctx := testutil.ContextWithTimeout(t, testTimeout) - web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, false) + web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, nil, false) require.NoError(t, err) globalContext.web = web @@ -445,12 +460,21 @@ func TestAuth_ServeHTTP_auth(t *testing.T) { Name: userName, PasswordHash: string(passwordHash), }} - auth := InitAuth(sessionsDB, users, testTTL, nil, nil) - t.Cleanup(auth.Close) - globalContext.auth = auth - mux := http.NewServeMux() - globalContext.mux = mux + auth, err := newAuth(testutil.ContextWithTimeout(t, testTimeout), &authConfig{ + baseLogger: testLogger, + rateLimiter: emptyRateLimiter{}, + trustedProxies: nil, + dbFilename: sessionsDB, + users: users, + sessionTTL: testTTL * time.Second, + isGLiNet: false, + }) + require.NoError(t, err) + + t.Cleanup(func() { auth.close(testutil.ContextWithTimeout(t, testTimeout)) }) + + globalContext.mux = http.NewServeMux() tlsMgr, err := newTLSManager(testutil.ContextWithTimeout(t, testTimeout), &tlsManagerConfig{ logger: testLogger, @@ -459,11 +483,16 @@ func TestAuth_ServeHTTP_auth(t *testing.T) { require.NoError(t, err) ctx := testutil.ContextWithTimeout(t, testTimeout) - web, err := initWeb(ctx, options{}, nil, nil, testLogger, tlsMgr, false) + web, err := initWeb(ctx, options{}, nil, nil, testLogger, tlsMgr, auth, false) require.NoError(t, err) globalContext.web = web + mux := auth.middleware().Wrap(globalContext.mux) + + auth.isGLiNet = true + gliNetMw := auth.middleware().Wrap(globalContext.mux) + loginCookie := generateAuthCookie(t, mux, userName, userPassword) testCases := []struct { @@ -506,7 +535,7 @@ func TestAuth_ServeHTTP_auth(t *testing.T) { for _, tc := range testCases { t.Run(tc.path, func(t *testing.T) { r := httptest.NewRequest(tc.method, tc.path, nil) - assertHandlerStatusCode(t, mux, r, http.StatusForbidden) + assertHandlerStatusCode(t, mux, r, http.StatusUnauthorized) r = httptest.NewRequest(tc.method, tc.path, nil) r.SetBasicAuth(userName, userPassword) @@ -516,18 +545,15 @@ func TestAuth_ServeHTTP_auth(t *testing.T) { r.AddCookie(loginCookie) assertHandlerStatusCode(t, mux, r, tc.wantCode) - GLMode = true - t.Cleanup(func() { GLMode = false }) - r.AddCookie(&http.Cookie{Name: glCookieName, Value: "test"}) - assertHandlerStatusCode(t, mux, r, tc.wantCode) + assertHandlerStatusCode(t, gliNetMw, r, tc.wantCode) }) } } // generateAuthCookie is a helper function that logs in with the provided // credentials and returns the resulting authentication cookie. -func generateAuthCookie(t *testing.T, mux *http.ServeMux, name, password string) (ac *http.Cookie) { +func generateAuthCookie(t *testing.T, mux http.Handler, name, password string) (ac *http.Cookie) { t.Helper() creds, err := json.Marshal(&loginJSON{Name: name, Password: password}) @@ -541,11 +567,15 @@ func generateAuthCookie(t *testing.T, mux *http.ServeMux, name, password string) for _, c := range w.Result().Cookies() { if c.Name == sessionCookieName { - return c + ac = c + + break } } - return nil + require.NotNil(t, ac) + + return ac } // assertHandlerStatusCode is a helper function that asserts the response status @@ -578,21 +608,31 @@ func TestAuth_ServeHTTP_logout(t *testing.T) { Name: userName, PasswordHash: string(passwordHash), }} - auth := InitAuth(sessionsDB, users, testTTL, nil, nil) - t.Cleanup(auth.Close) - globalContext.auth = auth - mux := http.NewServeMux() - globalContext.mux = mux + auth, err := newAuth(testutil.ContextWithTimeout(t, testTimeout), &authConfig{ + baseLogger: testLogger, + rateLimiter: emptyRateLimiter{}, + trustedProxies: nil, + dbFilename: sessionsDB, + users: users, + sessionTTL: testTTL * time.Second, + isGLiNet: false, + }) + require.NoError(t, err) + + t.Cleanup(func() { auth.close(testutil.ContextWithTimeout(t, testTimeout)) }) + + globalContext.mux = http.NewServeMux() ctx := testutil.ContextWithTimeout(t, testTimeout) - web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, false) + web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, auth, false) require.NoError(t, err) globalContext.web = web + mux := auth.middleware().Wrap(globalContext.mux) + loginCookie := generateAuthCookie(t, mux, userName, userPassword) - require.NotNil(t, loginCookie) r := httptest.NewRequest(http.MethodGet, "/control/profile", nil) r.AddCookie(loginCookie) @@ -604,110 +644,7 @@ func TestAuth_ServeHTTP_logout(t *testing.T) { r = httptest.NewRequest(http.MethodGet, "/control/profile", nil) r.AddCookie(loginCookie) - assertHandlerStatusCode(t, mux, r, http.StatusForbidden) -} - -// implements http.ResponseWriter -type testResponseWriter struct { - hdr http.Header - statusCode int -} - -func (w *testResponseWriter) Header() http.Header { - return w.hdr -} - -func (w *testResponseWriter) Write([]byte) (int, error) { - return 0, nil -} - -func (w *testResponseWriter) WriteHeader(statusCode int) { - w.statusCode = statusCode -} - -func TestAuthHTTP(t *testing.T) { - dir := t.TempDir() - fn := filepath.Join(dir, "sessions.db") - - users := []webUser{ - {Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"}, - } - globalContext.auth = InitAuth(fn, users, 60, nil, nil) - - handlerCalled := false - handler := func(_ http.ResponseWriter, _ *http.Request) { - handlerCalled = true - } - handler2 := optionalAuth(handler) - w := testResponseWriter{} - w.hdr = make(http.Header) - r := http.Request{} - r.Header = make(http.Header) - r.Method = http.MethodGet - - // get / - we're redirected to login page - r.URL = &url.URL{Path: "/"} - handlerCalled = false - handler2(&w, &r) - assert.Equal(t, http.StatusFound, w.statusCode) - assert.NotEmpty(t, w.hdr.Get(httphdr.Location)) - assert.False(t, handlerCalled) - - // go to login page - loginURL := w.hdr.Get(httphdr.Location) - r.URL = &url.URL{Path: loginURL} - handlerCalled = false - handler2(&w, &r) - assert.True(t, handlerCalled) - - // perform login - cookie, err := globalContext.auth.newCookie(loginJSON{Name: "name", Password: "password"}, "") - require.NoError(t, err) - require.NotNil(t, cookie) - - // get / - handler2 = optionalAuth(handler) - w.hdr = make(http.Header) - r.Header.Set(httphdr.Cookie, cookie.String()) - r.URL = &url.URL{Path: "/"} - handlerCalled = false - handler2(&w, &r) - assert.True(t, handlerCalled) - - r.Header.Del(httphdr.Cookie) - - // get / with basic auth - handler2 = optionalAuth(handler) - w.hdr = make(http.Header) - r.URL = &url.URL{Path: "/"} - r.SetBasicAuth("name", "password") - handlerCalled = false - handler2(&w, &r) - assert.True(t, handlerCalled) - r.Header.Del(httphdr.Authorization) - - // get login page with a valid cookie - we're redirected to / - handler2 = optionalAuth(handler) - w.hdr = make(http.Header) - r.Header.Set(httphdr.Cookie, cookie.String()) - r.URL = &url.URL{Path: loginURL} - handlerCalled = false - handler2(&w, &r) - assert.NotEmpty(t, w.hdr.Get(httphdr.Location)) - assert.False(t, handlerCalled) - r.Header.Del(httphdr.Cookie) - - // get login page with an invalid cookie - handler2 = optionalAuth(handler) - w.hdr = make(http.Header) - r.Header.Set(httphdr.Cookie, "bad") - r.URL = &url.URL{Path: loginURL} - handlerCalled = false - handler2(&w, &r) - assert.True(t, handlerCalled) - r.Header.Del(httphdr.Cookie) - - globalContext.auth.Close() + assertHandlerStatusCode(t, mux, r, http.StatusUnauthorized) } func TestRealIP(t *testing.T) { diff --git a/internal/home/authratelimiter.go b/internal/home/authratelimiter.go index fcd2c127..95e8d571 100644 --- a/internal/home/authratelimiter.go +++ b/internal/home/authratelimiter.go @@ -9,6 +9,38 @@ import ( // cache. const failedAuthTTL = 1 * time.Minute +// loginRaateLimiter is an interface for rate limiting login attempts. +type loginRaateLimiter interface { + // check returns the duration of time left until a user is unblocked. + // A non-positive result indicates that the user is not blocked. + check(usrID string) (left time.Duration) + + // inc records a failed login attempt for the specified user. + inc(usrID string) + + // remove stops tracking and blocking of the specified user. + remove(usrID string) +} + +// emptyRateLimiter is the [loginRateLimiter] interface implementation that does +// nothing. +type emptyRateLimiter struct{} + +// type check +var _ emptyRateLimiter = emptyRateLimiter{} + +// check implements the [loginRateLimiter] interface for emptyRateLimiter. It +// always returns zero. +func (rl emptyRateLimiter) check(_ string) (left time.Duration) { + return 0 +} + +// inc implements the [loginRateLimiter] interface for emptyRateLimiter. +func (rl emptyRateLimiter) inc(_ string) {} + +// remove implements the [loginRateLimiter] interface for emptyRateLimiter. +func (rl emptyRateLimiter) remove(_ string) {} + // failedAuth is an entry of authRateLimiter's cache. type failedAuth struct { until time.Time @@ -33,6 +65,9 @@ func newAuthRateLimiter(blockDur time.Duration, maxAttempts uint) (ab *authRateL } } +// type check +var _ loginRaateLimiter = (*authRateLimiter)(nil) + // cleanupLocked checks each blocked users removing ones with expired TTL. For // internal use only. func (ab *authRateLimiter) cleanupLocked(now time.Time) { @@ -57,8 +92,7 @@ func (ab *authRateLimiter) checkLocked(usrID string, now time.Time) (left time.D return a.until.Sub(now) } -// check returns the time left until unblocking. The nonpositive result should -// be interpreted as not blocked attempter. +// check implements the [loginRateLimiter] interface for *authRateLimiter. func (ab *authRateLimiter) check(usrID string) (left time.Duration) { now := time.Now() @@ -91,7 +125,7 @@ func (ab *authRateLimiter) incLocked(usrID string, now time.Time) { } } -// inc updates the failed attempt in cache. +// inc implements the [loginRateLimiter] interface for *authRateLimiter. func (ab *authRateLimiter) inc(usrID string) { now := time.Now() @@ -101,7 +135,7 @@ func (ab *authRateLimiter) inc(usrID string) { ab.incLocked(usrID, now) } -// remove stops any tracking and any blocking of the user. +// remove implements the [loginRateLimiter] interface for *authRateLimiter. func (ab *authRateLimiter) remove(usrID string) { ab.failedAuthsLock.Lock() defer ab.failedAuthsLock.Unlock() diff --git a/internal/home/config.go b/internal/home/config.go index 2ec549d3..ac6636d7 100644 --- a/internal/home/config.go +++ b/internal/home/config.go @@ -2,6 +2,7 @@ package home import ( "bytes" + "context" "fmt" "net/netip" "os" @@ -743,12 +744,13 @@ func readConfigFile() (fileData []byte, err error) { } // Saves configuration to the YAML file and also saves the user filter contents to a file -func (c *configuration) write(tlsMgr *tlsManager) (err error) { +func (c *configuration) write(tlsMgr *tlsManager, auth *auth) (err error) { c.Lock() defer c.Unlock() - if globalContext.auth != nil { - config.Users = globalContext.auth.usersList() + if auth != nil { + // TODO(s.chzhen): Pass context. + config.Users = auth.usersList(context.TODO()) } if tlsMgr != nil { diff --git a/internal/home/control.go b/internal/home/control.go index 4be955f3..45168805 100644 --- a/internal/home/control.go +++ b/internal/home/control.go @@ -171,22 +171,19 @@ func (web *webAPI) handleStatus(w http.ResponseWriter, r *http.Request) { // registerControlHandlers sets up HTTP handlers for various control endpoints. // web must not be nil. func registerControlHandlers(web *webAPI) { - globalContext.mux.HandleFunc( - "/control/version.json", - postInstall(optionalAuth(web.handleVersionJSON)), - ) + globalContext.mux.HandleFunc("/control/version.json", postInstall(web.handleVersionJSON)) httpRegister(http.MethodPost, "/control/update", web.handleUpdate) httpRegister(http.MethodGet, "/control/status", web.handleStatus) httpRegister(http.MethodPost, "/control/i18n/change_language", handleI18nChangeLanguage) httpRegister(http.MethodGet, "/control/i18n/current_language", handleI18nCurrentLanguage) - httpRegister(http.MethodGet, "/control/profile", handleGetProfile) + httpRegister(http.MethodGet, "/control/profile", web.handleGetProfile) httpRegister(http.MethodPut, "/control/profile/update", handlePutProfile) // No auth is necessary for DoH/DoT configurations globalContext.mux.HandleFunc("/apple/doh.mobileconfig", postInstall(handleMobileConfigDoH)) globalContext.mux.HandleFunc("/apple/dot.mobileconfig", postInstall(handleMobileConfigDoT)) - RegisterAuthHandlers() + RegisterAuthHandlers(web) } // httpRegister registers an HTTP handler. @@ -197,7 +194,10 @@ func httpRegister(method, url string, handler http.HandlerFunc) { return } - globalContext.mux.Handle(url, postInstallHandler(optionalAuthHandler(gziphandler.GzipHandler(ensureHandler(method, handler))))) + globalContext.mux.Handle( + url, + postInstallHandler(gziphandler.GzipHandler(ensureHandler(method, handler))), + ) } // ensure returns a wrapped handler that makes sure that the request has the diff --git a/internal/home/controlinstall.go b/internal/home/controlinstall.go index 602b0f64..50d36a91 100644 --- a/internal/home/controlinstall.go +++ b/internal/home/controlinstall.go @@ -392,6 +392,8 @@ const PasswordMinRunes = 8 // Apply new configuration, start DNS server, restart Web server func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + req, restartHTTP, err := decodeApplyConfigReq(r.Body) if err != nil { aghhttp.Error(r, w, http.StatusBadRequest, "%s", err) @@ -440,7 +442,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request u := &webUser{ Name: req.Username, } - err = globalContext.auth.addUser(u, req.Password) + err = web.auth.addUser(ctx, u, req.Password) if err != nil { globalContext.firstRun = true copyInstallSettings(config, curConfig) @@ -453,7 +455,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request // moment we'll allow setting up TLS in the initial configuration or the // configuration itself will use HTTPS protocol, because the underlying // functions potentially restart the HTTPS server. - err = startMods(r.Context(), web.baseLogger, web.tlsManager) + err = startMods(ctx, web.baseLogger, web.tlsManager) if err != nil { globalContext.firstRun = true copyInstallSettings(config, curConfig) @@ -462,7 +464,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request return } - err = config.write(web.tlsManager) + err = config.write(web.tlsManager, web.auth) if err != nil { globalContext.firstRun = true copyInstallSettings(config, curConfig) @@ -489,11 +491,11 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request // and with its own context, because it waits until all requests are handled // and will be blocked by it's own caller. go func(timeout time.Duration) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer slogutil.RecoverAndLog(ctx, web.logger) + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) + defer slogutil.RecoverAndLog(shutdownCtx, web.logger) defer cancel() - shutdownSrv(ctx, web.logger, web.httpServer) + shutdownSrv(shutdownCtx, web.logger, web.httpServer) }(shutdownTimeout) } diff --git a/internal/home/dns.go b/internal/home/dns.go index dea65bf7..79fcbb4e 100644 --- a/internal/home/dns.go +++ b/internal/home/dns.go @@ -42,7 +42,7 @@ const ( // // TODO(s.chzhen): Remove this after refactoring. func onConfigModified() { - err := config.write(globalContext.tls) + err := config.write(globalContext.tls, globalContext.auth) if err != nil { log.Error("writing config: %s", err) } diff --git a/internal/home/home.go b/internal/home/home.go index f38a350f..4e377f28 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -48,14 +48,20 @@ type homeContext struct { // Modules // -- - clients clientsContainer // per-client-settings module - stats stats.Interface // statistics module - queryLog querylog.QueryLog // query log module - dnsServer *dnsforward.Server // DNS module - dhcpServer dhcpd.Interface // DHCP module - auth *Auth // HTTP authentication module - filters *filtering.DNSFilter // DNS filtering module - web *webAPI // Web (HTTP, HTTPS) module + clients clientsContainer // per-client-settings module + stats stats.Interface // statistics module + queryLog querylog.QueryLog // query log module + dnsServer *dnsforward.Server // DNS module + dhcpServer dhcpd.Interface // DHCP module + + // auth stores web user information and handles authentication. + // + // TODO(s.chzhen): Remove once it is no longer called from different + // modules. See [onConfigModified]. + auth *auth + + filters *filtering.DNSFilter // DNS filtering module + web *webAPI // Web (HTTP, HTTPS) module // tls contains the current configuration and state of TLS encryption. // @@ -531,8 +537,8 @@ func isUpdateEnabled( } } -// initWeb initializes the web module. upd, baseLogger, and tlsMgr must not be -// nil. +// initWeb initializes the web module. upd, baseLogger, tlsMgr, and auth must +// not be nil. func initWeb( ctx context.Context, opts options, @@ -540,6 +546,7 @@ func initWeb( upd *updater.Updater, baseLogger *slog.Logger, tlsMgr *tlsManager, + auth *auth, isCustomUpdURL bool, ) (web *webAPI, err error) { logger := baseLogger.With(slogutil.KeyPrefix, "webapi") @@ -563,6 +570,7 @@ func initWeb( logger: logger, baseLogger: baseLogger, tlsManager: tlsMgr, + auth: auth, clientFS: clientFS, @@ -671,7 +679,7 @@ func run(opts options, clientBuildFS fs.FS, done chan struct{}, sigHdlr *signalH if !globalContext.firstRun { // Save the updated config. - err = config.write(nil) + err = config.write(nil, nil) fatalOnError(err) if config.HTTPConfig.Pprof.Enabled { @@ -683,13 +691,12 @@ func run(opts options, clientBuildFS fs.FS, done chan struct{}, sigHdlr *signalH err = os.MkdirAll(dataDir, aghos.DefaultPermDir) fatalOnError(errors.Annotate(err, "creating DNS data dir at %s: %w", dataDir)) - GLMode = opts.glinetMode - - // Init auth module. - globalContext.auth, err = initUsers() + auth, err := initUsers(ctx, slogLogger, opts.glinetMode) fatalOnError(err) - web, err := initWeb(ctx, opts, clientBuildFS, upd, slogLogger, tlsMgr, isCustomURL) + globalContext.auth = auth + + web, err := initWeb(ctx, opts, clientBuildFS, upd, slogLogger, tlsMgr, auth, isCustomURL) fatalOnError(err) globalContext.web = web @@ -805,24 +812,33 @@ func checkPermissions( permcheck.Check(ctx, l, workDir, dataDir, statsDir, querylogDir, confPath) } -// initUsers initializes context auth module. Clears config users field. -func initUsers() (auth *Auth, err error) { - sessFilename := filepath.Join(globalContext.getDataDir(), "sessions.db") - - var rateLimiter *authRateLimiter +// initUsers initializes authentication module and clears the [config.Users] +// field. +func initUsers( + ctx context.Context, + baseLogger *slog.Logger, + isGLiNet bool, +) (auth *auth, err error) { + var rateLimiter loginRaateLimiter if config.AuthAttempts > 0 && config.AuthBlockMin > 0 { blockDur := time.Duration(config.AuthBlockMin) * time.Minute rateLimiter = newAuthRateLimiter(blockDur, config.AuthAttempts) } else { - log.Info("authratelimiter is disabled") + baseLogger.WarnContext(ctx, "authratelimiter is disabled") + rateLimiter = emptyRateLimiter{} } - trustedProxies := netutil.SliceSubnetSet(netutil.UnembedPrefixes(config.DNS.TrustedProxies)) - - sessionTTL := time.Duration(config.HTTPConfig.SessionTTL).Seconds() - auth = InitAuth(sessFilename, config.Users, uint32(sessionTTL), rateLimiter, trustedProxies) - if auth == nil { - return nil, errors.Error("initializing auth module failed") + auth, err = newAuth(ctx, &authConfig{ + baseLogger: baseLogger, + rateLimiter: rateLimiter, + trustedProxies: netutil.SliceSubnetSet(netutil.UnembedPrefixes(config.DNS.TrustedProxies)), + dbFilename: filepath.Join(globalContext.getDataDir(), sessionsDBName), + users: config.Users, + sessionTTL: time.Duration(config.HTTPConfig.SessionTTL), + isGLiNet: isGLiNet, + }) + if err != nil { + return nil, fmt.Errorf("initializing auth module: %w", err) } config.Users = nil @@ -935,10 +951,6 @@ func cleanup(ctx context.Context) { globalContext.web.close(ctx) globalContext.web = nil } - if globalContext.auth != nil { - globalContext.auth.Close() - globalContext.auth = nil - } err := stopDNSServer() if err != nil { diff --git a/internal/home/profilehttp.go b/internal/home/profilehttp.go index 0b1dcf99..8c3d6ef0 100644 --- a/internal/home/profilehttp.go +++ b/internal/home/profilehttp.go @@ -46,8 +46,18 @@ type profileJSON struct { } // handleGetProfile is the handler for GET /control/profile endpoint. -func handleGetProfile(w http.ResponseWriter, r *http.Request) { - u := globalContext.auth.getCurrentUser(r) +func (web *webAPI) handleGetProfile(w http.ResponseWriter, r *http.Request) { + var name string + if !web.auth.isGLiNet { + u, ok := webUserFromContext(r.Context()) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + name = string(u.Login) + } var resp profileJSON func() { @@ -55,7 +65,7 @@ func handleGetProfile(w http.ResponseWriter, r *http.Request) { defer config.RUnlock() resp = profileJSON{ - Name: u.Name, + Name: name, Language: config.Language, Theme: config.Theme, } diff --git a/internal/home/tls_internal_test.go b/internal/home/tls_internal_test.go index b65d0a97..ad05fd44 100644 --- a/internal/home/tls_internal_test.go +++ b/internal/home/tls_internal_test.go @@ -111,7 +111,6 @@ func TestValidateCertificates(t *testing.T) { // restores them once the test is complete. // // The global variables are: -// - [GLMode] // - [config] // - [glFilePrefix] // - [globalContext.auth] @@ -126,10 +125,8 @@ func TestValidateCertificates(t *testing.T) { func storeGlobals(tb testing.TB) { tb.Helper() - prevGLMode := GLMode prevConfig := config prefGLFilePrefix := glFilePrefix - auth := globalContext.auth storage := globalContext.clients.storage dnsServer := globalContext.dnsServer firstRun := globalContext.firstRun @@ -137,10 +134,8 @@ func storeGlobals(tb testing.TB) { web := globalContext.web tb.Cleanup(func() { - GLMode = prevGLMode config = prevConfig glFilePrefix = prefGLFilePrefix - globalContext.auth = auth globalContext.clients.storage = storage globalContext.dnsServer = dnsServer globalContext.firstRun = firstRun @@ -262,7 +257,7 @@ func TestTLSManager_Reload(t *testing.T) { }) require.NoError(t, err) - web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, false) + web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, nil, false) require.NoError(t, err) m.setWebAPI(web) @@ -332,7 +327,7 @@ func TestValidateTLSSettings(t *testing.T) { }) require.NoError(t, err) - web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, false) + web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, nil, false) require.NoError(t, err) m.setWebAPI(web) @@ -436,7 +431,7 @@ func TestTLSManager_HandleTLSValidate(t *testing.T) { }) require.NoError(t, err) - web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, false) + web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, nil, false) require.NoError(t, err) m.setWebAPI(web) @@ -527,7 +522,7 @@ func TestTLSManager_HandleTLSConfigure(t *testing.T) { }) require.NoError(t, err) - web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, false) + web, err := initWeb(ctx, options{}, nil, nil, testLogger, nil, nil, false) require.NoError(t, err) m.setWebAPI(web) diff --git a/internal/home/web.go b/internal/home/web.go index 9de27782..96d4852f 100644 --- a/internal/home/web.go +++ b/internal/home/web.go @@ -51,6 +51,10 @@ type webConfig struct { // encryption. It must not be nil. tlsManager *tlsManager + // auth stores web user information and handles authentication. It must not + // be nil. + auth *auth + clientFS fs.FS // BindAddr is the binding address with port for plain HTTP web interface. @@ -114,6 +118,9 @@ type webAPI struct { // encryption. tlsManager *tlsManager + // auth stores web user information and handles authentication. + auth *auth + // httpsServer is the server that handles HTTPS traffic. If it is not nil, // [Web.http3Server] must also not be nil. httpsServer httpsServer @@ -131,12 +138,16 @@ func newWebAPI(ctx context.Context, conf *webConfig) (w *webAPI) { logger: conf.logger, baseLogger: conf.baseLogger, tlsManager: conf.tlsManager, + auth: conf.auth, } clientFS := http.FileServer(http.FS(conf.clientFS)) // if not configured, redirect / to /install.html, otherwise redirect /install.html to / - globalContext.mux.Handle("/", withMiddlewares(clientFS, gziphandler.GzipHandler, optionalAuthHandler, postInstallHandler)) + globalContext.mux.Handle( + "/", + withMiddlewares(clientFS, gziphandler.GzipHandler, postInstallHandler), + ) // add handlers for /install paths, we only need them when we're not configured yet if conf.firstRun { @@ -210,7 +221,10 @@ func (web *webAPI) start(ctx context.Context) { errs := make(chan error, 2) // Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies. - hdlr := h2c.NewHandler(withMiddlewares(globalContext.mux, limitRequestBody), &http2.Server{}) + hdlr := h2c.NewHandler( + withMiddlewares(globalContext.mux, limitRequestBody), + &http2.Server{}, + ) logger := web.baseLogger.With(loggerKeyServer, "plain") @@ -221,7 +235,7 @@ func (web *webAPI) start(ctx context.Context) { // Create a new instance, because the Web is not usable after Shutdown. web.httpServer = &http.Server{ Addr: web.conf.BindAddr.String(), - Handler: hdlr, + Handler: web.auth.middleware().Wrap(hdlr), ReadTimeout: web.conf.ReadTimeout, ReadHeaderTimeout: web.conf.ReadHeaderTimeout, WriteTimeout: web.conf.WriteTimeout, @@ -262,6 +276,10 @@ func (web *webAPI) close(ctx context.Context) { shutdownSrv3(ctx, web.logger, web.httpsServer.server3) shutdownSrv(ctx, web.logger, web.httpServer) + if web.auth != nil { + web.auth.close(ctx) + } + web.logger.InfoContext(ctx, "stopped http server") } @@ -303,7 +321,7 @@ func (web *webAPI) tlsServerLoop(ctx context.Context) { web.httpsServer.server = &http.Server{ Addr: addr, - Handler: hdlr, + Handler: web.auth.middleware().Wrap(hdlr), TLSConfig: &tls.Config{ Certificates: []tls.Certificate{web.httpsServer.cert}, RootCAs: web.tlsManager.rootCerts, @@ -344,7 +362,7 @@ func (web *webAPI) mustStartHTTP3(ctx context.Context, address string) { CipherSuites: web.tlsManager.customCipherIDs, MinVersion: tls.VersionTLS12, }, - Handler: withMiddlewares(globalContext.mux, limitRequestBody), + Handler: web.auth.middleware().Wrap(withMiddlewares(globalContext.mux, limitRequestBody)), } web.logger.DebugContext(ctx, "starting http/3 server")