mirror of
https://git.vectorsigma.ru/public/AdGuardHome.git
synced 2026-08-03 20:59:27 +00:00
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 <s.chzhen@adguard.com>
Date: Wed Jul 9 17:09:26 2025 +0300
Merge branch 'master' into AGDNS-2743-auth-mw-usage
commit 610c6fc45d2d8848e9b89afb0037b98d83106afa
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Tue Jul 8 19:03:44 2025 +0300
home: imp docs
commit 4633c8991ca77ec182b17a299d9f7e75e145f2ee
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Thu Jul 3 21:01:54 2025 +0300
home: add tests
commit 586b714dafc342670b441fb29e3c7c8fde83ba4c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Thu Jul 3 14:25:28 2025 +0300
home: fix first run
commit 7c2e5d41f0feaae7b50ea0e69cf2767ba17d59df
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Wed Jul 2 23:52:16 2025 +0300
home: imp code
commit b90031495ae3b6fab7361528fb3a3013139a4965
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Wed Jul 2 14:06:42 2025 +0300
home: rm unused
commit 90cb29f2daac7b825b03857414bb04a692736e22
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Wed Jul 2 13:55:45 2025 +0300
home: auth mw usage
This commit is contained in:
@@ -1,317 +1,162 @@
|
|||||||
package home
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"log/slog"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
|
"github.com/AdguardTeam/AdGuardHome/internal/aghuser"
|
||||||
"github.com/AdguardTeam/golibs/errors"
|
"github.com/AdguardTeam/golibs/errors"
|
||||||
"github.com/AdguardTeam/golibs/log"
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||||
"github.com/AdguardTeam/golibs/netutil"
|
"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"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionTokenSize is the length of session token in bytes.
|
// sessionsDBName is the name of the file where session data is stored.
|
||||||
const sessionTokenSize = 16
|
const sessionsDBName = "sessions.db"
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// webUser represents a user of the Web UI.
|
// webUser represents a user of the Web UI.
|
||||||
//
|
//
|
||||||
// TODO(s.chzhen): Improve naming.
|
// TODO(s.chzhen): Improve naming.
|
||||||
type webUser struct {
|
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"`
|
PasswordHash string `yaml:"password"`
|
||||||
|
|
||||||
|
// UserID is the unique identifier of the web user.
|
||||||
|
UserID aghuser.UserID `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitAuth initializes the global authentication object.
|
// toUser returns the new properly initialized *aghuser.User using stored
|
||||||
func InitAuth(
|
// properties. It panics if there is an error generating the user ID.
|
||||||
dbFilename string,
|
func (wu *webUser) toUser() (u *aghuser.User) {
|
||||||
users []webUser,
|
uid := wu.UserID
|
||||||
sessionTTL uint32,
|
if uid == (aghuser.UserID{}) {
|
||||||
rateLimiter *authRateLimiter,
|
uid = aghuser.MustNewUserID()
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
|
|
||||||
a.db, err = bbolt.Open(dbFilename, aghos.DefaultPermFile, nil)
|
return &aghuser.User{
|
||||||
if err != nil {
|
Password: aghuser.NewDefaultPassword(wu.PasswordHash),
|
||||||
log.Error("auth: open DB: %s: %s", dbFilename, err)
|
Login: aghuser.Login(wu.Name),
|
||||||
if err.Error() == "invalid argument" {
|
ID: uid,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
a.loadSessions()
|
|
||||||
log.Info("auth: initialized. users:%d sessions:%d", len(a.users), len(a.sessions))
|
|
||||||
|
|
||||||
return a
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the authentication database.
|
// authConfig is the configuration structure for [auth].
|
||||||
func (a *Auth) Close() {
|
type authConfig struct {
|
||||||
_ = a.db.Close()
|
// 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 {
|
// auth stores web user information and handles authentication.
|
||||||
return []byte("sessions-2")
|
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
|
// newAuth returns the new properly initialized *auth.
|
||||||
// sessions.
|
func newAuth(ctx context.Context, conf *authConfig) (a *auth, err error) {
|
||||||
func (a *Auth) loadSessions() {
|
userDB := aghuser.NewDefaultDB()
|
||||||
tx, err := a.db.Begin(true)
|
for i, u := range conf.users {
|
||||||
if err != nil {
|
err = userDB.Create(ctx, u.toUser())
|
||||||
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()
|
|
||||||
if err != nil {
|
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
|
// middleware returns authentication middleware.
|
||||||
// database file.
|
func (a *auth) middleware() (mw httputil.Middleware) {
|
||||||
func (a *Auth) addSession(data []byte, s *session) {
|
if a.isGLiNet {
|
||||||
name := hex.EncodeToString(data)
|
return newAuthMiddlewareGLiNet(&authMiddlewareGLiNetConfig{
|
||||||
a.lock.Lock()
|
logger: a.logger,
|
||||||
a.sessions[name] = s
|
clock: timeutil.SystemClock{},
|
||||||
a.lock.Unlock()
|
tokenFilePrefix: glFilePrefix,
|
||||||
if a.storeSession(data, s) {
|
ttl: glTokenTimeout,
|
||||||
log.Debug("auth: created session %s: expire=%d", name, s.expire)
|
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.
|
// usersList returns a copy of a users list.
|
||||||
func (a *Auth) storeSession(data []byte, s *session) bool {
|
func (a *auth) usersList(ctx context.Context) (webUsers []webUser) {
|
||||||
tx, err := a.db.Begin(true)
|
users, err := a.users.All(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("auth: bbolt.Begin: %s", err)
|
// Should not happen.
|
||||||
|
panic(err)
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
bkt, err := tx.CreateBucketIfNotExists(bucketName())
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.CreateBucketIfNotExists: %s", err)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = bkt.Put(data, s.serialize())
|
webUsers = make([]webUser, 0, len(users))
|
||||||
if err != nil {
|
for _, u := range users {
|
||||||
log.Error("auth: bbolt.Put: %s", err)
|
webUsers = append(webUsers, webUser{
|
||||||
|
Name: string(u.Login),
|
||||||
return false
|
PasswordHash: string(u.Password.Hash()),
|
||||||
|
UserID: u.ID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
return webUsers
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.Commit: %s", err)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeSessionFromFile removes a stored session from the DB file on disk.
|
// addUser adds a new user with the given password. u must not be nil.
|
||||||
func (a *Auth) removeSessionFromFile(sess []byte) {
|
func (a *auth) addUser(ctx context.Context, u *webUser, password string) (err error) {
|
||||||
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) {
|
|
||||||
if len(password) == 0 {
|
if len(password) == 0 {
|
||||||
return errors.Error("empty password")
|
return errors.Error("empty password")
|
||||||
}
|
}
|
||||||
@@ -323,97 +168,21 @@ func (a *Auth) addUser(u *webUser, password string) (err error) {
|
|||||||
|
|
||||||
u.PasswordHash = string(hash)
|
u.PasswordHash = string(hash)
|
||||||
|
|
||||||
a.lock.Lock()
|
err = a.users.Create(ctx, u.toUser())
|
||||||
defer a.lock.Unlock()
|
if err != nil {
|
||||||
|
// Should not happen.
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
a.users = append(a.users, *u)
|
a.logger.DebugContext(ctx, "added user", "login", u.Name)
|
||||||
|
|
||||||
log.Debug("auth: added user with login %q", u.Name)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findUser returns a user if there is one.
|
// close closes the authentication database.
|
||||||
func (a *Auth) findUser(login, password string) (u webUser, ok bool) {
|
func (a *auth) close(ctx context.Context) {
|
||||||
a.lock.Lock()
|
err := a.sessions.Close()
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// There's no Cookie, check Basic authentication.
|
a.logger.ErrorContext(ctx, "closing session storage", slogutil.KeyError, err)
|
||||||
user, pass, ok := r.BasicAuth()
|
|
||||||
if ok {
|
|
||||||
u, _ = globalContext.auth.findUser(user, pass)
|
|
||||||
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
||||||
return webUser{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,52 @@
|
|||||||
package home
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/AdguardTeam/AdGuardHome/internal/aghuser"
|
||||||
|
"github.com/AdguardTeam/golibs/testutil"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuth(t *testing.T) {
|
func TestAuth_UsersList(t *testing.T) {
|
||||||
dir := t.TempDir()
|
const (
|
||||||
fn := filepath.Join(dir, "sessions.db")
|
userName = "name"
|
||||||
|
userPassword = "password"
|
||||||
|
)
|
||||||
|
|
||||||
users := []webUser{{
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userPassword), bcrypt.DefaultCost)
|
||||||
Name: "name",
|
|
||||||
PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
|
|
||||||
}}
|
|
||||||
a := InitAuth(fn, nil, 60, nil, nil)
|
|
||||||
s := session{}
|
|
||||||
|
|
||||||
user := webUser{Name: "name"}
|
|
||||||
err := a.addUser(&user, "password")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
|
sessionsDB := filepath.Join(t.TempDir(), "sessions.db")
|
||||||
a.removeSession("notfound")
|
|
||||||
|
|
||||||
sess := newSessionToken()
|
user := webUser{
|
||||||
sessStr := hex.EncodeToString(sess)
|
Name: userName,
|
||||||
|
PasswordHash: string(passwordHash),
|
||||||
|
UserID: aghuser.MustNewUserID(),
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().UTC().Unix()
|
auth, err := newAuth(testutil.ContextWithTimeout(t, testTimeout), &authConfig{
|
||||||
// check expiration
|
baseLogger: testLogger,
|
||||||
s.expire = uint32(now)
|
rateLimiter: emptyRateLimiter{},
|
||||||
a.addSession(sess, &s)
|
trustedProxies: nil,
|
||||||
assert.Equal(t, checkSessionExpired, a.checkSession(sessStr))
|
dbFilename: sessionsDB,
|
||||||
|
users: nil,
|
||||||
|
sessionTTL: testTimeout,
|
||||||
|
isGLiNet: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// add session with TTL = 2 sec
|
t.Cleanup(func() { auth.close(testutil.ContextWithTimeout(t, testTimeout)) })
|
||||||
s = session{}
|
|
||||||
s.expire = uint32(time.Now().UTC().Unix() + 2)
|
|
||||||
a.addSession(sess, &s)
|
|
||||||
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
|
||||||
|
|
||||||
a.Close()
|
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
|
||||||
// load saved session
|
assert.Empty(t, auth.usersList(ctx))
|
||||||
a = InitAuth(fn, users, 60, nil, nil)
|
|
||||||
|
|
||||||
// the session is still alive
|
err = auth.addUser(ctx, &user, userPassword)
|
||||||
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
require.NoError(t, err)
|
||||||
// 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.Equal(t, []webUser{user}, auth.usersList(ctx))
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,40 @@
|
|||||||
package home
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AdguardTeam/golibs/ioutil"
|
|
||||||
"github.com/AdguardTeam/golibs/log"
|
|
||||||
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||||
"github.com/AdguardTeam/golibs/netutil/httputil"
|
"github.com/AdguardTeam/golibs/netutil/httputil"
|
||||||
|
"github.com/AdguardTeam/golibs/netutil/urlutil"
|
||||||
"github.com/AdguardTeam/golibs/timeutil"
|
"github.com/AdguardTeam/golibs/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GLMode - enable GL-Inet compatibility mode
|
// glFilePrefix is the prefix of the filepath where the authentication token is
|
||||||
var GLMode bool
|
// 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_"
|
var glFilePrefix = "/tmp/gl_token_"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
glTokenTimeoutSeconds = 3600
|
// glTokenTimeout is the TTL (Time To Live) of the authentication token.
|
||||||
glCookieName = "Admin-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.
|
// MaxFileSize is a maximum file length in bytes.
|
||||||
const MaxFileSize = 1024 * 1024
|
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
|
// authMiddlewareGLiNetConfig is the configuration structure for the GLiNet
|
||||||
// authentication middleware.
|
// authentication middleware.
|
||||||
type authMiddlewareGLiNetConfig struct {
|
type authMiddlewareGLiNetConfig struct {
|
||||||
@@ -166,12 +90,37 @@ var _ httputil.Middleware = (*authMiddlewareGLiNet)(nil)
|
|||||||
func (mw *authMiddlewareGLiNet) Wrap(h http.Handler) (wrapped http.Handler) {
|
func (mw *authMiddlewareGLiNet) Wrap(h http.Handler) (wrapped http.Handler) {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
|
path := r.URL.Path
|
||||||
|
if isPublicResource(path) {
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if mw.isAuthenticated(ctx, r) {
|
if mw.isAuthenticated(ctx, r) {
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestAuthMiddlewareGLiNet(t *testing.T) {
|
|||||||
}{{
|
}{{
|
||||||
req: httptest.NewRequest(http.MethodGet, "/", nil),
|
req: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||||
name: "no_cookie",
|
name: "no_cookie",
|
||||||
wantCode: http.StatusUnauthorized,
|
wantCode: http.StatusFound,
|
||||||
}, {
|
}, {
|
||||||
req: reqValidCookie,
|
req: reqValidCookie,
|
||||||
name: "valid_cookie",
|
name: "valid_cookie",
|
||||||
@@ -64,7 +64,7 @@ func TestAuthMiddlewareGLiNet(t *testing.T) {
|
|||||||
}, {
|
}, {
|
||||||
req: reqInvalidCookie,
|
req: reqInvalidCookie,
|
||||||
name: "invalid_cookie",
|
name: "invalid_cookie",
|
||||||
wantCode: http.StatusUnauthorized,
|
wantCode: http.StatusFound,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path"
|
"path"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -37,40 +38,6 @@ type loginJSON struct {
|
|||||||
Password string `json:"password"`
|
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
|
// realIP extracts the real IP address of the client from an HTTP request using
|
||||||
// the known HTTP headers.
|
// the known HTTP headers.
|
||||||
//
|
//
|
||||||
@@ -130,7 +97,9 @@ func writeErrorWithIP(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleLogin is the handler for the POST /control/login HTTP API.
|
// 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{}
|
req := loginJSON{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&req)
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,8 +109,8 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var remoteIP string
|
var remoteIP string
|
||||||
// realIP cannot be used here without taking TrustedProxies into account due
|
// The real IP address of the client [realIP] cannot be used here without
|
||||||
// to security issues.
|
// taking trusted proxies into account due to security issues:
|
||||||
//
|
//
|
||||||
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
|
// See https://github.com/AdguardTeam/AdGuardHome/issues/2799.
|
||||||
if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil {
|
if remoteIP, err = netutil.SplitHost(r.RemoteAddr); err != nil {
|
||||||
@@ -157,7 +126,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if rateLimiter := globalContext.auth.rateLimiter; rateLimiter != nil {
|
if rateLimiter := web.auth.rateLimiter; rateLimiter != nil {
|
||||||
if left := rateLimiter.check(remoteIP); left > 0 {
|
if left := rateLimiter.check(remoteIP); left > 0 {
|
||||||
w.Header().Set(httphdr.RetryAfter, strconv.Itoa(int(left.Seconds())))
|
w.Header().Set(httphdr.RetryAfter, strconv.Itoa(int(left.Seconds())))
|
||||||
writeErrorWithIP(
|
writeErrorWithIP(
|
||||||
@@ -175,13 +144,18 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ip, err := realIP(r)
|
ip, err := realIP(r)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
logIP := remoteIP
|
logIP := remoteIP
|
||||||
if globalContext.auth.trustedProxies.Contains(ip.Unmap()) {
|
if web.auth.trustedProxies.Contains(ip.Unmap()) {
|
||||||
logIP = ip.String()
|
logIP = ip.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +164,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
@@ -202,8 +176,54 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
aghhttp.OK(w)
|
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.
|
// 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()
|
respHdr := w.Header()
|
||||||
c, err := r.Cookie(sessionCookieName)
|
c, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,7 +235,19 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
c = &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
@@ -233,93 +265,12 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAuthHandlers - register handlers
|
// RegisterAuthHandlers - register handlers
|
||||||
func RegisterAuthHandlers() {
|
func RegisterAuthHandlers(web *webAPI) {
|
||||||
globalContext.mux.Handle("/control/login", postInstallHandler(ensureHandler(http.MethodPost, handleLogin)))
|
globalContext.mux.Handle(
|
||||||
httpRegister(http.MethodGet, "/control/logout", handleLogout)
|
"/control/login",
|
||||||
}
|
postInstallHandler(ensureHandler(http.MethodPost, web.handleLogin)),
|
||||||
|
)
|
||||||
// optionalAuthThird returns true if a user should authenticate first.
|
httpRegister(http.MethodGet, "/control/logout", web.handleLogout)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPublicResource returns true if p is a path to a public resource.
|
// 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))
|
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].
|
return isAsset || isLogin || slices.Contains(paths, p)
|
||||||
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}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -367,6 +315,15 @@ type authMiddlewareDefaultConfig struct {
|
|||||||
// be nil.
|
// be nil.
|
||||||
logger *slog.Logger
|
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 contains web user sessions. It must not be nil.
|
||||||
sessions aghuser.SessionStorage
|
sessions aghuser.SessionStorage
|
||||||
|
|
||||||
@@ -378,18 +335,22 @@ type authMiddlewareDefaultConfig struct {
|
|||||||
// for a web client using an authentication cookie or basic auth credentials and
|
// for a web client using an authentication cookie or basic auth credentials and
|
||||||
// passes it with the context.
|
// passes it with the context.
|
||||||
type authMiddlewareDefault struct {
|
type authMiddlewareDefault struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
sessions aghuser.SessionStorage
|
rateLimiter loginRaateLimiter
|
||||||
users aghuser.DB
|
trustedProxies netutil.SubnetSet
|
||||||
|
sessions aghuser.SessionStorage
|
||||||
|
users aghuser.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAuthMiddlewareDefault returns the new properly initialized
|
// newAuthMiddlewareDefault returns the new properly initialized
|
||||||
// *authMiddlewareDefault.
|
// *authMiddlewareDefault.
|
||||||
func newAuthMiddlewareDefault(c *authMiddlewareDefaultConfig) (mw *authMiddlewareDefault) {
|
func newAuthMiddlewareDefault(c *authMiddlewareDefaultConfig) (mw *authMiddlewareDefault) {
|
||||||
return &authMiddlewareDefault{
|
return &authMiddlewareDefault{
|
||||||
logger: c.logger,
|
logger: c.logger,
|
||||||
sessions: c.sessions,
|
rateLimiter: c.rateLimiter,
|
||||||
users: c.users,
|
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) {
|
func (mw *authMiddlewareDefault) Wrap(h http.Handler) (wrapped http.Handler) {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
if !mw.needsAuthentication(ctx, r) {
|
|
||||||
|
if !mw.needsAuthentication(ctx) {
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path := r.URL.Path
|
||||||
u, err := mw.userFromRequest(ctx, r)
|
u, err := mw.userFromRequest(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
mw.logger.ErrorContext(ctx, "retrieving user from request", slogutil.KeyError, err)
|
||||||
|
}
|
||||||
|
|
||||||
if u != nil {
|
if u != nil {
|
||||||
|
if path == "/login.html" {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
h.ServeHTTP(w, r.WithContext(withWebUser(ctx, u)))
|
h.ServeHTTP(w, r.WithContext(withWebUser(ctx, u)))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if isPublicResource(path) {
|
||||||
mw.logger.ErrorContext(ctx, "retrieving user from request", slogutil.KeyError, err)
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "/" || path == "/index.html" {
|
||||||
|
http.Redirect(w, r, "login.html", http.StatusFound)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// needsAuthentication returns true if the current request requires
|
// needsAuthentication returns true if there are stored web users and requests
|
||||||
// authentication.
|
// should be authenticated first.
|
||||||
//
|
func (mw *authMiddlewareDefault) needsAuthentication(ctx context.Context) (ok bool) {
|
||||||
// TODO(s.chzhen): Use the request's path.
|
|
||||||
func (mw *authMiddlewareDefault) needsAuthentication(
|
|
||||||
ctx context.Context,
|
|
||||||
_ *http.Request,
|
|
||||||
) (ok bool) {
|
|
||||||
users, err := mw.users.All(ctx)
|
users, err := mw.users.All(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Should not happen.
|
// Should not happen.
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(users) == 0 {
|
return len(users) != 0
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
func (mw *authMiddlewareDefault) userFromRequest(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
@@ -451,25 +424,24 @@ func (mw *authMiddlewareDefault) userFromRequest(
|
|||||||
defer func() { err = errors.Annotate(err, "getting user from request: %w") }()
|
defer func() { err = errors.Annotate(err, "getting user from request: %w") }()
|
||||||
|
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err == http.ErrNoCookie {
|
if err == nil {
|
||||||
return mw.userFromRequestBasicAuth(ctx, r)
|
return mw.userFromCookie(ctx, cookie.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := hex.DecodeString(cookie.Value)
|
return mw.userFromRequestBasicAuth(ctx, r)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("decoding cookie: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
l := aghuser.SessionTokenLength
|
// userFromCookie tries to retrieve a user based on the provided cookie value.
|
||||||
|
func (mw *authMiddlewareDefault) userFromCookie(
|
||||||
// TODO(a.garipov): Add validate.Len.
|
ctx context.Context,
|
||||||
err = validate.InRange("token length", len(sess), l, l)
|
val string,
|
||||||
|
) (u *aghuser.User, err error) {
|
||||||
|
t, err := sessionTokenFromHex(val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Don't wrap the error because it's informative enough as is.
|
// Don't wrap the error because it's informative enough as is.
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
t := aghuser.SessionToken(sess)
|
|
||||||
s, err := mw.sessions.FindByToken(ctx, t)
|
s, err := mw.sessions.FindByToken(ctx, t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("searching session by token: %w", err)
|
return nil, fmt.Errorf("searching session by token: %w", err)
|
||||||
@@ -487,16 +459,58 @@ func (mw *authMiddlewareDefault) userFromRequest(
|
|||||||
return u, nil
|
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(
|
func (mw *authMiddlewareDefault) userFromRequestBasicAuth(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) (user *aghuser.User, err error) {
|
) (user *aghuser.User, err error) {
|
||||||
login, pass, ok := r.BasicAuth()
|
login, pass, ok := r.BasicAuth()
|
||||||
if !ok {
|
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))
|
user, _ = mw.users.ByLogin(ctx, aghuser.Login(login))
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, errInvalidLogin
|
return nil, errInvalidLogin
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -166,40 +165,18 @@ func (h *testAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.user, _ = webUserFromContext(r.Context())
|
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) {
|
func TestAuthMiddlewareDefault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
login aghuser.Login = "user_login"
|
loginStr = "user_login"
|
||||||
|
passwordStr = "user_password"
|
||||||
|
|
||||||
passwordRaw = "user_password"
|
login = aghuser.Login(loginStr)
|
||||||
)
|
)
|
||||||
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword(
|
passwordHash, err := bcrypt.GenerateFromPassword(
|
||||||
[]byte(passwordRaw),
|
[]byte(passwordStr),
|
||||||
bcrypt.DefaultCost,
|
bcrypt.DefaultCost,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -238,22 +215,14 @@ func TestAuthMiddlewareDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{
|
mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{
|
||||||
logger: testLogger,
|
logger: testLogger,
|
||||||
sessions: ts,
|
rateLimiter: emptyRateLimiter{},
|
||||||
users: usersDB,
|
sessions: ts,
|
||||||
|
users: usersDB,
|
||||||
})
|
})
|
||||||
|
|
||||||
reqCookie := httptest.NewRequest(http.MethodGet, "/", nil)
|
cookie := &http.Cookie{Name: sessionCookieName, Value: tokenHex}
|
||||||
reqCookie.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tokenHex})
|
invalidCookie := &http.Cookie{Name: sessionCookieName, Value: "123"}
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
req *http.Request
|
req *http.Request
|
||||||
@@ -263,28 +232,58 @@ func TestAuthMiddlewareDefault(t *testing.T) {
|
|||||||
}{{
|
}{{
|
||||||
req: httptest.NewRequest(http.MethodGet, "/", nil),
|
req: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||||
wantUser: nil,
|
wantUser: nil,
|
||||||
name: "no_auth",
|
name: "no_auth_root",
|
||||||
wantCode: http.StatusUnauthorized,
|
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,
|
wantUser: user,
|
||||||
name: "cookie",
|
name: "cookie",
|
||||||
wantCode: http.StatusOK,
|
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,
|
wantUser: user,
|
||||||
name: "basic_auth",
|
name: "basic_auth",
|
||||||
wantCode: http.StatusOK,
|
wantCode: http.StatusOK,
|
||||||
}, {
|
}, {
|
||||||
req: reqInvalidCookie,
|
req: authRequest("/", invalidCookie, "", ""),
|
||||||
wantUser: nil,
|
wantUser: nil,
|
||||||
name: "invalid_cookie",
|
name: "invalid_cookie",
|
||||||
wantCode: http.StatusUnauthorized,
|
wantCode: http.StatusFound,
|
||||||
}, {
|
}, {
|
||||||
req: reqInvalidPassBasicAuth,
|
req: authRequest("/", nil, "invalid", "creds"),
|
||||||
wantUser: nil,
|
wantUser: nil,
|
||||||
name: "invalid_basic_auth",
|
name: "invalid_basic_auth",
|
||||||
wantCode: http.StatusUnauthorized,
|
wantCode: http.StatusFound,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
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) {
|
func TestAuth_ServeHTTP_firstRun(t *testing.T) {
|
||||||
storeGlobals(t)
|
storeGlobals(t)
|
||||||
|
|
||||||
@@ -312,7 +327,7 @@ func TestAuth_ServeHTTP_firstRun(t *testing.T) {
|
|||||||
globalContext.mux = mux
|
globalContext.mux = mux
|
||||||
|
|
||||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
globalContext.web = web
|
globalContext.web = web
|
||||||
@@ -445,12 +460,21 @@ func TestAuth_ServeHTTP_auth(t *testing.T) {
|
|||||||
Name: userName,
|
Name: userName,
|
||||||
PasswordHash: string(passwordHash),
|
PasswordHash: string(passwordHash),
|
||||||
}}
|
}}
|
||||||
auth := InitAuth(sessionsDB, users, testTTL, nil, nil)
|
|
||||||
t.Cleanup(auth.Close)
|
|
||||||
globalContext.auth = auth
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
auth, err := newAuth(testutil.ContextWithTimeout(t, testTimeout), &authConfig{
|
||||||
globalContext.mux = mux
|
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{
|
tlsMgr, err := newTLSManager(testutil.ContextWithTimeout(t, testTimeout), &tlsManagerConfig{
|
||||||
logger: testLogger,
|
logger: testLogger,
|
||||||
@@ -459,11 +483,16 @@ func TestAuth_ServeHTTP_auth(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
globalContext.web = web
|
globalContext.web = web
|
||||||
|
|
||||||
|
mux := auth.middleware().Wrap(globalContext.mux)
|
||||||
|
|
||||||
|
auth.isGLiNet = true
|
||||||
|
gliNetMw := auth.middleware().Wrap(globalContext.mux)
|
||||||
|
|
||||||
loginCookie := generateAuthCookie(t, mux, userName, userPassword)
|
loginCookie := generateAuthCookie(t, mux, userName, userPassword)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
@@ -506,7 +535,7 @@ func TestAuth_ServeHTTP_auth(t *testing.T) {
|
|||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.path, func(t *testing.T) {
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
r := httptest.NewRequest(tc.method, tc.path, nil)
|
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 = httptest.NewRequest(tc.method, tc.path, nil)
|
||||||
r.SetBasicAuth(userName, userPassword)
|
r.SetBasicAuth(userName, userPassword)
|
||||||
@@ -516,18 +545,15 @@ func TestAuth_ServeHTTP_auth(t *testing.T) {
|
|||||||
r.AddCookie(loginCookie)
|
r.AddCookie(loginCookie)
|
||||||
assertHandlerStatusCode(t, mux, r, tc.wantCode)
|
assertHandlerStatusCode(t, mux, r, tc.wantCode)
|
||||||
|
|
||||||
GLMode = true
|
|
||||||
t.Cleanup(func() { GLMode = false })
|
|
||||||
|
|
||||||
r.AddCookie(&http.Cookie{Name: glCookieName, Value: "test"})
|
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
|
// generateAuthCookie is a helper function that logs in with the provided
|
||||||
// credentials and returns the resulting authentication cookie.
|
// 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()
|
t.Helper()
|
||||||
|
|
||||||
creds, err := json.Marshal(&loginJSON{Name: name, Password: password})
|
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() {
|
for _, c := range w.Result().Cookies() {
|
||||||
if c.Name == sessionCookieName {
|
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
|
// assertHandlerStatusCode is a helper function that asserts the response status
|
||||||
@@ -578,21 +608,31 @@ func TestAuth_ServeHTTP_logout(t *testing.T) {
|
|||||||
Name: userName,
|
Name: userName,
|
||||||
PasswordHash: string(passwordHash),
|
PasswordHash: string(passwordHash),
|
||||||
}}
|
}}
|
||||||
auth := InitAuth(sessionsDB, users, testTTL, nil, nil)
|
|
||||||
t.Cleanup(auth.Close)
|
|
||||||
globalContext.auth = auth
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
auth, err := newAuth(testutil.ContextWithTimeout(t, testTimeout), &authConfig{
|
||||||
globalContext.mux = mux
|
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)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
globalContext.web = web
|
globalContext.web = web
|
||||||
|
|
||||||
|
mux := auth.middleware().Wrap(globalContext.mux)
|
||||||
|
|
||||||
loginCookie := generateAuthCookie(t, mux, userName, userPassword)
|
loginCookie := generateAuthCookie(t, mux, userName, userPassword)
|
||||||
require.NotNil(t, loginCookie)
|
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "/control/profile", nil)
|
r := httptest.NewRequest(http.MethodGet, "/control/profile", nil)
|
||||||
r.AddCookie(loginCookie)
|
r.AddCookie(loginCookie)
|
||||||
@@ -604,110 +644,7 @@ func TestAuth_ServeHTTP_logout(t *testing.T) {
|
|||||||
|
|
||||||
r = httptest.NewRequest(http.MethodGet, "/control/profile", nil)
|
r = httptest.NewRequest(http.MethodGet, "/control/profile", nil)
|
||||||
r.AddCookie(loginCookie)
|
r.AddCookie(loginCookie)
|
||||||
assertHandlerStatusCode(t, mux, r, http.StatusForbidden)
|
assertHandlerStatusCode(t, mux, r, http.StatusUnauthorized)
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRealIP(t *testing.T) {
|
func TestRealIP(t *testing.T) {
|
||||||
|
|||||||
@@ -9,6 +9,38 @@ import (
|
|||||||
// cache.
|
// cache.
|
||||||
const failedAuthTTL = 1 * time.Minute
|
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.
|
// failedAuth is an entry of authRateLimiter's cache.
|
||||||
type failedAuth struct {
|
type failedAuth struct {
|
||||||
until time.Time
|
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
|
// cleanupLocked checks each blocked users removing ones with expired TTL. For
|
||||||
// internal use only.
|
// internal use only.
|
||||||
func (ab *authRateLimiter) cleanupLocked(now time.Time) {
|
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)
|
return a.until.Sub(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
// check returns the time left until unblocking. The nonpositive result should
|
// check implements the [loginRateLimiter] interface for *authRateLimiter.
|
||||||
// be interpreted as not blocked attempter.
|
|
||||||
func (ab *authRateLimiter) check(usrID string) (left time.Duration) {
|
func (ab *authRateLimiter) check(usrID string) (left time.Duration) {
|
||||||
now := time.Now()
|
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) {
|
func (ab *authRateLimiter) inc(usrID string) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -101,7 +135,7 @@ func (ab *authRateLimiter) inc(usrID string) {
|
|||||||
ab.incLocked(usrID, now)
|
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) {
|
func (ab *authRateLimiter) remove(usrID string) {
|
||||||
ab.failedAuthsLock.Lock()
|
ab.failedAuthsLock.Lock()
|
||||||
defer ab.failedAuthsLock.Unlock()
|
defer ab.failedAuthsLock.Unlock()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package home
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"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
|
// 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()
|
c.Lock()
|
||||||
defer c.Unlock()
|
defer c.Unlock()
|
||||||
|
|
||||||
if globalContext.auth != nil {
|
if auth != nil {
|
||||||
config.Users = globalContext.auth.usersList()
|
// TODO(s.chzhen): Pass context.
|
||||||
|
config.Users = auth.usersList(context.TODO())
|
||||||
}
|
}
|
||||||
|
|
||||||
if tlsMgr != nil {
|
if tlsMgr != nil {
|
||||||
|
|||||||
@@ -171,22 +171,19 @@ func (web *webAPI) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
// registerControlHandlers sets up HTTP handlers for various control endpoints.
|
// registerControlHandlers sets up HTTP handlers for various control endpoints.
|
||||||
// web must not be nil.
|
// web must not be nil.
|
||||||
func registerControlHandlers(web *webAPI) {
|
func registerControlHandlers(web *webAPI) {
|
||||||
globalContext.mux.HandleFunc(
|
globalContext.mux.HandleFunc("/control/version.json", postInstall(web.handleVersionJSON))
|
||||||
"/control/version.json",
|
|
||||||
postInstall(optionalAuth(web.handleVersionJSON)),
|
|
||||||
)
|
|
||||||
httpRegister(http.MethodPost, "/control/update", web.handleUpdate)
|
httpRegister(http.MethodPost, "/control/update", web.handleUpdate)
|
||||||
|
|
||||||
httpRegister(http.MethodGet, "/control/status", web.handleStatus)
|
httpRegister(http.MethodGet, "/control/status", web.handleStatus)
|
||||||
httpRegister(http.MethodPost, "/control/i18n/change_language", handleI18nChangeLanguage)
|
httpRegister(http.MethodPost, "/control/i18n/change_language", handleI18nChangeLanguage)
|
||||||
httpRegister(http.MethodGet, "/control/i18n/current_language", handleI18nCurrentLanguage)
|
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)
|
httpRegister(http.MethodPut, "/control/profile/update", handlePutProfile)
|
||||||
|
|
||||||
// No auth is necessary for DoH/DoT configurations
|
// No auth is necessary for DoH/DoT configurations
|
||||||
globalContext.mux.HandleFunc("/apple/doh.mobileconfig", postInstall(handleMobileConfigDoH))
|
globalContext.mux.HandleFunc("/apple/doh.mobileconfig", postInstall(handleMobileConfigDoH))
|
||||||
globalContext.mux.HandleFunc("/apple/dot.mobileconfig", postInstall(handleMobileConfigDoT))
|
globalContext.mux.HandleFunc("/apple/dot.mobileconfig", postInstall(handleMobileConfigDoT))
|
||||||
RegisterAuthHandlers()
|
RegisterAuthHandlers(web)
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpRegister registers an HTTP handler.
|
// httpRegister registers an HTTP handler.
|
||||||
@@ -197,7 +194,10 @@ func httpRegister(method, url string, handler http.HandlerFunc) {
|
|||||||
return
|
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
|
// ensure returns a wrapped handler that makes sure that the request has the
|
||||||
|
|||||||
@@ -392,6 +392,8 @@ const PasswordMinRunes = 8
|
|||||||
|
|
||||||
// Apply new configuration, start DNS server, restart Web server
|
// Apply new configuration, start DNS server, restart Web server
|
||||||
func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
|
func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
req, restartHTTP, err := decodeApplyConfigReq(r.Body)
|
req, restartHTTP, err := decodeApplyConfigReq(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
|
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
|
||||||
@@ -440,7 +442,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request
|
|||||||
u := &webUser{
|
u := &webUser{
|
||||||
Name: req.Username,
|
Name: req.Username,
|
||||||
}
|
}
|
||||||
err = globalContext.auth.addUser(u, req.Password)
|
err = web.auth.addUser(ctx, u, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
globalContext.firstRun = true
|
globalContext.firstRun = true
|
||||||
copyInstallSettings(config, curConfig)
|
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
|
// moment we'll allow setting up TLS in the initial configuration or the
|
||||||
// configuration itself will use HTTPS protocol, because the underlying
|
// configuration itself will use HTTPS protocol, because the underlying
|
||||||
// functions potentially restart the HTTPS server.
|
// functions potentially restart the HTTPS server.
|
||||||
err = startMods(r.Context(), web.baseLogger, web.tlsManager)
|
err = startMods(ctx, web.baseLogger, web.tlsManager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
globalContext.firstRun = true
|
globalContext.firstRun = true
|
||||||
copyInstallSettings(config, curConfig)
|
copyInstallSettings(config, curConfig)
|
||||||
@@ -462,7 +464,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = config.write(web.tlsManager)
|
err = config.write(web.tlsManager, web.auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
globalContext.firstRun = true
|
globalContext.firstRun = true
|
||||||
copyInstallSettings(config, curConfig)
|
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 with its own context, because it waits until all requests are handled
|
||||||
// and will be blocked by it's own caller.
|
// and will be blocked by it's own caller.
|
||||||
go func(timeout time.Duration) {
|
go func(timeout time.Duration) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||||
defer slogutil.RecoverAndLog(ctx, web.logger)
|
defer slogutil.RecoverAndLog(shutdownCtx, web.logger)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
shutdownSrv(ctx, web.logger, web.httpServer)
|
shutdownSrv(shutdownCtx, web.logger, web.httpServer)
|
||||||
}(shutdownTimeout)
|
}(shutdownTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const (
|
|||||||
//
|
//
|
||||||
// TODO(s.chzhen): Remove this after refactoring.
|
// TODO(s.chzhen): Remove this after refactoring.
|
||||||
func onConfigModified() {
|
func onConfigModified() {
|
||||||
err := config.write(globalContext.tls)
|
err := config.write(globalContext.tls, globalContext.auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("writing config: %s", err)
|
log.Error("writing config: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,14 +48,20 @@ type homeContext struct {
|
|||||||
// Modules
|
// Modules
|
||||||
// --
|
// --
|
||||||
|
|
||||||
clients clientsContainer // per-client-settings module
|
clients clientsContainer // per-client-settings module
|
||||||
stats stats.Interface // statistics module
|
stats stats.Interface // statistics module
|
||||||
queryLog querylog.QueryLog // query log module
|
queryLog querylog.QueryLog // query log module
|
||||||
dnsServer *dnsforward.Server // DNS module
|
dnsServer *dnsforward.Server // DNS module
|
||||||
dhcpServer dhcpd.Interface // DHCP module
|
dhcpServer dhcpd.Interface // DHCP module
|
||||||
auth *Auth // HTTP authentication module
|
|
||||||
filters *filtering.DNSFilter // DNS filtering module
|
// auth stores web user information and handles authentication.
|
||||||
web *webAPI // Web (HTTP, HTTPS) module
|
//
|
||||||
|
// 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.
|
// 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
|
// initWeb initializes the web module. upd, baseLogger, tlsMgr, and auth must
|
||||||
// nil.
|
// not be nil.
|
||||||
func initWeb(
|
func initWeb(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
opts options,
|
opts options,
|
||||||
@@ -540,6 +546,7 @@ func initWeb(
|
|||||||
upd *updater.Updater,
|
upd *updater.Updater,
|
||||||
baseLogger *slog.Logger,
|
baseLogger *slog.Logger,
|
||||||
tlsMgr *tlsManager,
|
tlsMgr *tlsManager,
|
||||||
|
auth *auth,
|
||||||
isCustomUpdURL bool,
|
isCustomUpdURL bool,
|
||||||
) (web *webAPI, err error) {
|
) (web *webAPI, err error) {
|
||||||
logger := baseLogger.With(slogutil.KeyPrefix, "webapi")
|
logger := baseLogger.With(slogutil.KeyPrefix, "webapi")
|
||||||
@@ -563,6 +570,7 @@ func initWeb(
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
baseLogger: baseLogger,
|
baseLogger: baseLogger,
|
||||||
tlsManager: tlsMgr,
|
tlsManager: tlsMgr,
|
||||||
|
auth: auth,
|
||||||
|
|
||||||
clientFS: clientFS,
|
clientFS: clientFS,
|
||||||
|
|
||||||
@@ -671,7 +679,7 @@ func run(opts options, clientBuildFS fs.FS, done chan struct{}, sigHdlr *signalH
|
|||||||
|
|
||||||
if !globalContext.firstRun {
|
if !globalContext.firstRun {
|
||||||
// Save the updated config.
|
// Save the updated config.
|
||||||
err = config.write(nil)
|
err = config.write(nil, nil)
|
||||||
fatalOnError(err)
|
fatalOnError(err)
|
||||||
|
|
||||||
if config.HTTPConfig.Pprof.Enabled {
|
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)
|
err = os.MkdirAll(dataDir, aghos.DefaultPermDir)
|
||||||
fatalOnError(errors.Annotate(err, "creating DNS data dir at %s: %w", dataDir))
|
fatalOnError(errors.Annotate(err, "creating DNS data dir at %s: %w", dataDir))
|
||||||
|
|
||||||
GLMode = opts.glinetMode
|
auth, err := initUsers(ctx, slogLogger, opts.glinetMode)
|
||||||
|
|
||||||
// Init auth module.
|
|
||||||
globalContext.auth, err = initUsers()
|
|
||||||
fatalOnError(err)
|
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)
|
fatalOnError(err)
|
||||||
|
|
||||||
globalContext.web = web
|
globalContext.web = web
|
||||||
@@ -805,24 +812,33 @@ func checkPermissions(
|
|||||||
permcheck.Check(ctx, l, workDir, dataDir, statsDir, querylogDir, confPath)
|
permcheck.Check(ctx, l, workDir, dataDir, statsDir, querylogDir, confPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initUsers initializes context auth module. Clears config users field.
|
// initUsers initializes authentication module and clears the [config.Users]
|
||||||
func initUsers() (auth *Auth, err error) {
|
// field.
|
||||||
sessFilename := filepath.Join(globalContext.getDataDir(), "sessions.db")
|
func initUsers(
|
||||||
|
ctx context.Context,
|
||||||
var rateLimiter *authRateLimiter
|
baseLogger *slog.Logger,
|
||||||
|
isGLiNet bool,
|
||||||
|
) (auth *auth, err error) {
|
||||||
|
var rateLimiter loginRaateLimiter
|
||||||
if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
|
if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
|
||||||
blockDur := time.Duration(config.AuthBlockMin) * time.Minute
|
blockDur := time.Duration(config.AuthBlockMin) * time.Minute
|
||||||
rateLimiter = newAuthRateLimiter(blockDur, config.AuthAttempts)
|
rateLimiter = newAuthRateLimiter(blockDur, config.AuthAttempts)
|
||||||
} else {
|
} else {
|
||||||
log.Info("authratelimiter is disabled")
|
baseLogger.WarnContext(ctx, "authratelimiter is disabled")
|
||||||
|
rateLimiter = emptyRateLimiter{}
|
||||||
}
|
}
|
||||||
|
|
||||||
trustedProxies := netutil.SliceSubnetSet(netutil.UnembedPrefixes(config.DNS.TrustedProxies))
|
auth, err = newAuth(ctx, &authConfig{
|
||||||
|
baseLogger: baseLogger,
|
||||||
sessionTTL := time.Duration(config.HTTPConfig.SessionTTL).Seconds()
|
rateLimiter: rateLimiter,
|
||||||
auth = InitAuth(sessFilename, config.Users, uint32(sessionTTL), rateLimiter, trustedProxies)
|
trustedProxies: netutil.SliceSubnetSet(netutil.UnembedPrefixes(config.DNS.TrustedProxies)),
|
||||||
if auth == nil {
|
dbFilename: filepath.Join(globalContext.getDataDir(), sessionsDBName),
|
||||||
return nil, errors.Error("initializing auth module failed")
|
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
|
config.Users = nil
|
||||||
@@ -935,10 +951,6 @@ func cleanup(ctx context.Context) {
|
|||||||
globalContext.web.close(ctx)
|
globalContext.web.close(ctx)
|
||||||
globalContext.web = nil
|
globalContext.web = nil
|
||||||
}
|
}
|
||||||
if globalContext.auth != nil {
|
|
||||||
globalContext.auth.Close()
|
|
||||||
globalContext.auth = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := stopDNSServer()
|
err := stopDNSServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -46,8 +46,18 @@ type profileJSON struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleGetProfile is the handler for GET /control/profile endpoint.
|
// handleGetProfile is the handler for GET /control/profile endpoint.
|
||||||
func handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
func (web *webAPI) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
u := globalContext.auth.getCurrentUser(r)
|
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
|
var resp profileJSON
|
||||||
func() {
|
func() {
|
||||||
@@ -55,7 +65,7 @@ func handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer config.RUnlock()
|
defer config.RUnlock()
|
||||||
|
|
||||||
resp = profileJSON{
|
resp = profileJSON{
|
||||||
Name: u.Name,
|
Name: name,
|
||||||
Language: config.Language,
|
Language: config.Language,
|
||||||
Theme: config.Theme,
|
Theme: config.Theme,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ func TestValidateCertificates(t *testing.T) {
|
|||||||
// restores them once the test is complete.
|
// restores them once the test is complete.
|
||||||
//
|
//
|
||||||
// The global variables are:
|
// The global variables are:
|
||||||
// - [GLMode]
|
|
||||||
// - [config]
|
// - [config]
|
||||||
// - [glFilePrefix]
|
// - [glFilePrefix]
|
||||||
// - [globalContext.auth]
|
// - [globalContext.auth]
|
||||||
@@ -126,10 +125,8 @@ func TestValidateCertificates(t *testing.T) {
|
|||||||
func storeGlobals(tb testing.TB) {
|
func storeGlobals(tb testing.TB) {
|
||||||
tb.Helper()
|
tb.Helper()
|
||||||
|
|
||||||
prevGLMode := GLMode
|
|
||||||
prevConfig := config
|
prevConfig := config
|
||||||
prefGLFilePrefix := glFilePrefix
|
prefGLFilePrefix := glFilePrefix
|
||||||
auth := globalContext.auth
|
|
||||||
storage := globalContext.clients.storage
|
storage := globalContext.clients.storage
|
||||||
dnsServer := globalContext.dnsServer
|
dnsServer := globalContext.dnsServer
|
||||||
firstRun := globalContext.firstRun
|
firstRun := globalContext.firstRun
|
||||||
@@ -137,10 +134,8 @@ func storeGlobals(tb testing.TB) {
|
|||||||
web := globalContext.web
|
web := globalContext.web
|
||||||
|
|
||||||
tb.Cleanup(func() {
|
tb.Cleanup(func() {
|
||||||
GLMode = prevGLMode
|
|
||||||
config = prevConfig
|
config = prevConfig
|
||||||
glFilePrefix = prefGLFilePrefix
|
glFilePrefix = prefGLFilePrefix
|
||||||
globalContext.auth = auth
|
|
||||||
globalContext.clients.storage = storage
|
globalContext.clients.storage = storage
|
||||||
globalContext.dnsServer = dnsServer
|
globalContext.dnsServer = dnsServer
|
||||||
globalContext.firstRun = firstRun
|
globalContext.firstRun = firstRun
|
||||||
@@ -262,7 +257,7 @@ func TestTLSManager_Reload(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
m.setWebAPI(web)
|
m.setWebAPI(web)
|
||||||
@@ -332,7 +327,7 @@ func TestValidateTLSSettings(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
m.setWebAPI(web)
|
m.setWebAPI(web)
|
||||||
@@ -436,7 +431,7 @@ func TestTLSManager_HandleTLSValidate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
m.setWebAPI(web)
|
m.setWebAPI(web)
|
||||||
@@ -527,7 +522,7 @@ func TestTLSManager_HandleTLSConfigure(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
m.setWebAPI(web)
|
m.setWebAPI(web)
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ type webConfig struct {
|
|||||||
// encryption. It must not be nil.
|
// encryption. It must not be nil.
|
||||||
tlsManager *tlsManager
|
tlsManager *tlsManager
|
||||||
|
|
||||||
|
// auth stores web user information and handles authentication. It must not
|
||||||
|
// be nil.
|
||||||
|
auth *auth
|
||||||
|
|
||||||
clientFS fs.FS
|
clientFS fs.FS
|
||||||
|
|
||||||
// BindAddr is the binding address with port for plain HTTP web interface.
|
// BindAddr is the binding address with port for plain HTTP web interface.
|
||||||
@@ -114,6 +118,9 @@ type webAPI struct {
|
|||||||
// encryption.
|
// encryption.
|
||||||
tlsManager *tlsManager
|
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,
|
// httpsServer is the server that handles HTTPS traffic. If it is not nil,
|
||||||
// [Web.http3Server] must also not be nil.
|
// [Web.http3Server] must also not be nil.
|
||||||
httpsServer httpsServer
|
httpsServer httpsServer
|
||||||
@@ -131,12 +138,16 @@ func newWebAPI(ctx context.Context, conf *webConfig) (w *webAPI) {
|
|||||||
logger: conf.logger,
|
logger: conf.logger,
|
||||||
baseLogger: conf.baseLogger,
|
baseLogger: conf.baseLogger,
|
||||||
tlsManager: conf.tlsManager,
|
tlsManager: conf.tlsManager,
|
||||||
|
auth: conf.auth,
|
||||||
}
|
}
|
||||||
|
|
||||||
clientFS := http.FileServer(http.FS(conf.clientFS))
|
clientFS := http.FileServer(http.FS(conf.clientFS))
|
||||||
|
|
||||||
// if not configured, redirect / to /install.html, otherwise redirect /install.html to /
|
// 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
|
// add handlers for /install paths, we only need them when we're not configured yet
|
||||||
if conf.firstRun {
|
if conf.firstRun {
|
||||||
@@ -210,7 +221,10 @@ func (web *webAPI) start(ctx context.Context) {
|
|||||||
errs := make(chan error, 2)
|
errs := make(chan error, 2)
|
||||||
|
|
||||||
// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
|
// 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")
|
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.
|
// Create a new instance, because the Web is not usable after Shutdown.
|
||||||
web.httpServer = &http.Server{
|
web.httpServer = &http.Server{
|
||||||
Addr: web.conf.BindAddr.String(),
|
Addr: web.conf.BindAddr.String(),
|
||||||
Handler: hdlr,
|
Handler: web.auth.middleware().Wrap(hdlr),
|
||||||
ReadTimeout: web.conf.ReadTimeout,
|
ReadTimeout: web.conf.ReadTimeout,
|
||||||
ReadHeaderTimeout: web.conf.ReadHeaderTimeout,
|
ReadHeaderTimeout: web.conf.ReadHeaderTimeout,
|
||||||
WriteTimeout: web.conf.WriteTimeout,
|
WriteTimeout: web.conf.WriteTimeout,
|
||||||
@@ -262,6 +276,10 @@ func (web *webAPI) close(ctx context.Context) {
|
|||||||
shutdownSrv3(ctx, web.logger, web.httpsServer.server3)
|
shutdownSrv3(ctx, web.logger, web.httpsServer.server3)
|
||||||
shutdownSrv(ctx, web.logger, web.httpServer)
|
shutdownSrv(ctx, web.logger, web.httpServer)
|
||||||
|
|
||||||
|
if web.auth != nil {
|
||||||
|
web.auth.close(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
web.logger.InfoContext(ctx, "stopped http server")
|
web.logger.InfoContext(ctx, "stopped http server")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +321,7 @@ func (web *webAPI) tlsServerLoop(ctx context.Context) {
|
|||||||
|
|
||||||
web.httpsServer.server = &http.Server{
|
web.httpsServer.server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: hdlr,
|
Handler: web.auth.middleware().Wrap(hdlr),
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
Certificates: []tls.Certificate{web.httpsServer.cert},
|
Certificates: []tls.Certificate{web.httpsServer.cert},
|
||||||
RootCAs: web.tlsManager.rootCerts,
|
RootCAs: web.tlsManager.rootCerts,
|
||||||
@@ -344,7 +362,7 @@ func (web *webAPI) mustStartHTTP3(ctx context.Context, address string) {
|
|||||||
CipherSuites: web.tlsManager.customCipherIDs,
|
CipherSuites: web.tlsManager.customCipherIDs,
|
||||||
MinVersion: tls.VersionTLS12,
|
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")
|
web.logger.DebugContext(ctx, "starting http/3 server")
|
||||||
|
|||||||
Reference in New Issue
Block a user