diff --git a/internal/aghuser/session.go b/internal/aghuser/session.go index 1bfe9023..ffe0ee0f 100644 --- a/internal/aghuser/session.go +++ b/internal/aghuser/session.go @@ -5,8 +5,11 @@ import ( "time" ) +// SessionTokenLength is the length of the web user session token. +const SessionTokenLength = 16 + // SessionToken is the type for the web user session token. -type SessionToken [16]byte +type SessionToken [SessionTokenLength]byte // NewSessionToken returns a cryptographically secure randomly generated web // user session token. If an error occurs during random generation, it will diff --git a/internal/home/authhttp.go b/internal/home/authhttp.go index 788dff80..a31237c4 100644 --- a/internal/home/authhttp.go +++ b/internal/home/authhttp.go @@ -1,9 +1,11 @@ package home import ( + "context" "encoding/hex" "encoding/json" "fmt" + "log/slog" "net/http" "net/netip" "path" @@ -12,11 +14,14 @@ import ( "time" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" + "github.com/AdguardTeam/AdGuardHome/internal/aghuser" "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/httphdr" "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/golibs/logutil/slogutil" "github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/timeutil" + "github.com/AdguardTeam/golibs/validate" ) // cookieTTL is the time-to-live of the session cookie. @@ -348,3 +353,154 @@ func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func optionalAuthHandler(handler http.Handler) http.Handler { return &authHandler{handler} } + +const ( + // errInvalidLogin is returned when there is an invalid login attempt. + errInvalidLogin errors.Error = "invalid username or password" +) + +// authMiddlewareDefaultConfig is the configuration structure for the default +// authentication middleware. +type authMiddlewareDefaultConfig struct { + // logger is used for logging the operation of the middleware. It must not + // be nil. + logger *slog.Logger + + // sessions contains web user sessions. It must not be nil. + sessions aghuser.SessionStorage + + // users contains web user information. It must not be nil. + users aghuser.DB +} + +// authMiddlewareDefault is the default authentication middleware. It searches +// for a web client using an authentication cookie or basic auth credentials and +// passes it with the context. +type authMiddlewareDefault struct { + logger *slog.Logger + sessions aghuser.SessionStorage + users aghuser.DB +} + +// newAuthMiddlewareDefault returns the new properly initialized +// *authMiddlewareDefault. +func newAuthMiddlewareDefault(c *authMiddlewareDefaultConfig) (mw *authMiddlewareDefault) { + return &authMiddlewareDefault{ + logger: c.logger, + sessions: c.sessions, + users: c.users, + } +} + +// wrap adds authentication logic to the provided HTTP handler. +func (mw *authMiddlewareDefault) wrap(h http.Handler) (wrapped http.Handler) { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !mw.needsAuthentication(ctx, r) { + h.ServeHTTP(w, r) + + return + } + + u, err := mw.userFromRequest(ctx, r) + if u != nil { + h.ServeHTTP(w, r.WithContext(withWebUser(ctx, u))) + + return + } + + if err != nil { + mw.logger.ErrorContext(ctx, "retrieving user from request", slogutil.KeyError, err) + } + + w.WriteHeader(http.StatusUnauthorized) + }) +} + +// needsAuthentication returns true if the current request requires +// authentication. +// +// TODO(s.chzhen): Use the request's path. +func (mw *authMiddlewareDefault) needsAuthentication( + ctx context.Context, + _ *http.Request, +) (ok bool) { + users, err := mw.users.All(ctx) + if err != nil { + // Should not happen. + panic(err) + } + + if len(users) == 0 { + return false + } + + return true +} + +// userFromRequest tries to retrieve a user based on the request. +func (mw *authMiddlewareDefault) userFromRequest( + ctx context.Context, + r *http.Request, +) (u *aghuser.User, err error) { + defer func() { err = errors.Annotate(err, "getting user from request: %w") }() + + cookie, err := r.Cookie(sessionCookieName) + if err == http.ErrNoCookie { + return mw.userFromRequestBasicAuth(ctx, r) + } + + sess, err := hex.DecodeString(cookie.Value) + if err != nil { + return nil, fmt.Errorf("decoding cookie: %w", err) + } + + l := aghuser.SessionTokenLength + + // TODO(a.garipov): Add validate.Len. + err = validate.InRange("token length", len(sess), l, l) + if err != nil { + // Don't wrap the error because it's informative enough as is. + return nil, err + } + + t := aghuser.SessionToken(sess) + s, err := mw.sessions.FindByToken(ctx, t) + if err != nil { + return nil, fmt.Errorf("searching session by token: %w", err) + } + + if s == nil { + return nil, nil + } + + u, err = mw.users.ByLogin(ctx, s.UserLogin) + if err != nil { + return nil, fmt.Errorf("searching user by login %q: %w", s.UserLogin, err) + } + + return u, nil +} + +// userFromRequestBasicAuth searches for a user using Basic Auth credentials. +func (mw *authMiddlewareDefault) userFromRequestBasicAuth( + ctx context.Context, + r *http.Request, +) (user *aghuser.User, err error) { + login, pass, ok := r.BasicAuth() + if !ok { + return nil, fmt.Errorf("credentials: %w", errors.ErrNoValue) + } + + user, _ = mw.users.ByLogin(ctx, aghuser.Login(login)) + if user == nil { + return nil, errInvalidLogin + } + + ok = user.Password.Authenticate(ctx, pass) + if !ok { + return nil, errInvalidLogin + } + + return user, nil +} diff --git a/internal/home/authhttp_internal_test.go b/internal/home/authhttp_internal_test.go index c7bea784..e827a9a9 100644 --- a/internal/home/authhttp_internal_test.go +++ b/internal/home/authhttp_internal_test.go @@ -2,7 +2,12 @@ package home import ( "bytes" + "context" + "crypto/rand" + "encoding/hex" "encoding/json" + "fmt" + "maps" "net/http" "net/http/httptest" "net/netip" @@ -10,10 +15,12 @@ import ( "net/url" "os" "path/filepath" + "slices" "testing" "time" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" + "github.com/AdguardTeam/AdGuardHome/internal/aghuser" "github.com/AdguardTeam/golibs/httphdr" "github.com/AdguardTeam/golibs/testutil" "github.com/josharian/native" @@ -22,6 +29,280 @@ import ( "golang.org/x/crypto/bcrypt" ) +// testSessionStorage is the mock implementation of the [aghuser.SessionStorage] +// interface. +type testSessionStorage struct { + onNew func(ctx context.Context, u *aghuser.User) (s *aghuser.Session, err error) + onFindByToken func( + ctx context.Context, + t aghuser.SessionToken, + ) (s *aghuser.Session, err error) + onDeleteByToken func(ctx context.Context, t aghuser.SessionToken) (err error) + onClose func() (err error) +} + +// type check +var _ aghuser.SessionStorage = (*testSessionStorage)(nil) + +// newTestSessionStorage returns a new *testSessionStorage all methods of which +// panic. +func newTestSessionStorage() (ts *testSessionStorage) { + return &testSessionStorage{ + onNew: func(_ context.Context, u *aghuser.User) (_ *aghuser.Session, _ error) { + panic(fmt.Errorf("unexpected call to testSessionStorage.New(%v)", u)) + }, + onFindByToken: func( + _ context.Context, + t aghuser.SessionToken, + ) (_ *aghuser.Session, err error) { + panic(fmt.Errorf("unexpected call to testSessionStorage.FindByToken(%v)", t)) + }, + onDeleteByToken: func(_ context.Context, t aghuser.SessionToken) (_ error) { + panic(fmt.Errorf("unexpected call to testSessionStorage.DeleteByToken(%v)", t)) + }, + onClose: func() (_ error) { + panic("unexpected call to testSessionStorage.Close") + }, + } +} + +// New implements the [aghuser.SessionStorage] interface for +// *testSessionStorage. +func (ts *testSessionStorage) New( + ctx context.Context, + u *aghuser.User, +) (s *aghuser.Session, err error) { + return ts.onNew(ctx, u) +} + +// FindByToken implements the [aghuser.SessionStorage] interface for +// *testSessionStorage. +func (ts *testSessionStorage) FindByToken( + ctx context.Context, + t aghuser.SessionToken, +) (s *aghuser.Session, err error) { + return ts.onFindByToken(ctx, t) +} + +// DeleteByToken implements the [aghuser.SessionStorage] interface for +// *testSessionStorage. +func (ts *testSessionStorage) DeleteByToken( + ctx context.Context, + t aghuser.SessionToken, +) (err error) { + return ts.onDeleteByToken(ctx, t) +} + +// Close implements the [aghuser.SessionStorage] interface for +// *testSessionStorage. +func (ts *testSessionStorage) Close() (err error) { + return ts.onClose() +} + +// testUsersDB is the mock implementation of the [aghuser.DB] interface. +type testUsersDB struct { + onAll func(ctx context.Context) (users []*aghuser.User, err error) + onByLogin func(ctx context.Context, login aghuser.Login) (u *aghuser.User, err error) + onByUUID func(ctx context.Context, id aghuser.UserID) (u *aghuser.User, err error) + onCreate func(ctx context.Context, u *aghuser.User) (err error) +} + +// newTestUsersDB returns a new *testUsersDB all methods of which panic. +func newTestUsersDB() (ts *testUsersDB) { + return &testUsersDB{ + onAll: func(_ context.Context) (_ []*aghuser.User, _ error) { + panic("unexpected call to testUsersDB.All") + }, + onByLogin: func(_ context.Context, l aghuser.Login) (_ *aghuser.User, _ error) { + panic(fmt.Errorf("unexpected call to testUsersDB.ByLogin(%v)", l)) + }, + onByUUID: func(_ context.Context, id aghuser.UserID) (_ *aghuser.User, _ error) { + panic(fmt.Errorf("unexpected call to testUsersDB.ByUUID(%v)", id)) + }, + onCreate: func(_ context.Context, u *aghuser.User) (_ error) { + panic(fmt.Errorf("unexpected call to testUsersDB.Create(%v)", u)) + }, + } +} + +// type check +var _ aghuser.DB = (*testUsersDB)(nil) + +// All implements the [aghuser.DB] interface for *testUsersDB. +func (db *testUsersDB) All(ctx context.Context) (users []*aghuser.User, err error) { + return db.onAll(ctx) +} + +// ByLogin implements the [aghuser.DB] interface for *testUsersDB. +func (db *testUsersDB) ByLogin( + ctx context.Context, + login aghuser.Login, +) (u *aghuser.User, err error) { + return db.onByLogin(ctx, login) +} + +// ByUUID implements the [aghuser.DB] interface for *testUsersDB. +func (db *testUsersDB) ByUUID(ctx context.Context, id aghuser.UserID) (u *aghuser.User, err error) { + return db.onByUUID(ctx, id) +} + +// Create implements the [aghuser.DB] interface for *testUsersDB. +func (db *testUsersDB) Create(ctx context.Context, u *aghuser.User) (err error) { + return db.onCreate(ctx, u) +} + +// testAuthHandler is a helper handler used for testing HTTP middleware. +type testAuthHandler struct { + user *aghuser.User + called bool +} + +// type check +var _ http.Handler = (*testAuthHandler)(nil) + +// ServeHTTP implements the [http.Handler] interface for *testAuthHandler. +func (h *testAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.called = true + h.user, _ = webUserFromContext(r.Context()) +} + +func TestAuthMiddlewareDefault_firstRun(t *testing.T) { + db := newTestUsersDB() + db.onAll = func(_ context.Context) (users []*aghuser.User, err error) { + return nil, nil + } + + mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{ + logger: testLogger, + sessions: &testSessionStorage{}, + users: db, + }) + + h := &testAuthHandler{} + wrapped := mw.wrap(h) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + wrapped.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, h.called) +} + +func TestAuthMiddlewareDefault(t *testing.T) { + t.Parallel() + + const ( + login aghuser.Login = "user_login" + + passwordRaw = "user_password" + ) + + passwordHash, err := bcrypt.GenerateFromPassword( + []byte(passwordRaw), + bcrypt.DefaultCost, + ) + require.NoError(t, err) + + user := &aghuser.User{ + Login: login, + Password: aghuser.NewDefaultPassword(string(passwordHash)), + } + + var token aghuser.SessionToken + _, _ = rand.Read(token[:]) + + tokenHex := hex.EncodeToString(token[:]) + + users := map[aghuser.Login]*aghuser.User{login: user} + usersDB := newTestUsersDB() + usersDB.onAll = func(_ context.Context) (us []*aghuser.User, err error) { + return slices.Collect(maps.Values(users)), nil + } + + usersDB.onByLogin = func(_ context.Context, login aghuser.Login) (u *aghuser.User, err error) { + return users[login], nil + } + + sessions := map[aghuser.SessionToken]*aghuser.Session{ + token: { + UserLogin: login, + }, + } + ts := newTestSessionStorage() + ts.onFindByToken = func( + _ context.Context, + t aghuser.SessionToken, + ) (s *aghuser.Session, err error) { + return sessions[t], nil + } + + mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{ + logger: testLogger, + sessions: ts, + users: usersDB, + }) + + reqCookie := httptest.NewRequest(http.MethodGet, "/", nil) + reqCookie.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tokenHex}) + + reqInvalidCookie := httptest.NewRequest(http.MethodGet, "/", nil) + reqInvalidCookie.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "invalid_cookie"}) + + reqBasicAuth := httptest.NewRequest(http.MethodGet, "/", nil) + reqBasicAuth.SetBasicAuth(string(login), passwordRaw) + + reqInvalidPassBasicAuth := httptest.NewRequest(http.MethodGet, "/", nil) + reqInvalidPassBasicAuth.SetBasicAuth(string(login), "invalid_password") + + testCases := []struct { + req *http.Request + wantUser *aghuser.User + name string + wantCode int + }{{ + req: httptest.NewRequest(http.MethodGet, "/", nil), + wantUser: nil, + name: "no_auth", + wantCode: http.StatusUnauthorized, + }, { + req: reqCookie, + wantUser: user, + name: "cookie", + wantCode: http.StatusOK, + }, { + req: reqBasicAuth, + wantUser: user, + name: "basic_auth", + wantCode: http.StatusOK, + }, { + req: reqInvalidCookie, + wantUser: nil, + name: "invalid_cookie", + wantCode: http.StatusUnauthorized, + }, { + req: reqInvalidPassBasicAuth, + wantUser: nil, + name: "invalid_basic_auth", + wantCode: http.StatusUnauthorized, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + h := &testAuthHandler{} + wrapped := mw.wrap(h) + + w := httptest.NewRecorder() + wrapped.ServeHTTP(w, tc.req) + + assert.Equal(t, tc.wantCode, w.Code) + assert.Equal(t, tc.wantUser, h.user) + }) + } +} + func TestAuth_ServeHTTP_firstRun(t *testing.T) { storeGlobals(t) diff --git a/internal/home/context.go b/internal/home/context.go new file mode 100644 index 00000000..fbd67594 --- /dev/null +++ b/internal/home/context.go @@ -0,0 +1,56 @@ +package home + +import ( + "context" + "fmt" + + "github.com/AdguardTeam/AdGuardHome/internal/aghuser" + "github.com/AdguardTeam/golibs/errors" +) + +// ctxKey is the type for context keys within this package. +type ctxKey uint8 + +const ( + ctxKeyWebUser ctxKey = iota +) + +// type check +var _ fmt.Stringer = ctxKey(0) + +// String implements the [fmt.Stringer] interface for ctxKey. +func (k ctxKey) String() (s string) { + switch k { + case ctxKeyWebUser: + return "ctxKeyWebUser" + default: + panic(fmt.Errorf("ctx key: %w: %d", errors.ErrBadEnumValue, k)) + } +} + +// panicBadType is a helper that panics with a message about the context key and +// the expected type. +func panicBadType(key ctxKey, v any) { + panic(fmt.Errorf("bad type for %s: %T(%[2]v)", key, v)) +} + +// withWebUser returns a copy of the parent context with the web user added. +func withWebUser(ctx context.Context, u *aghuser.User) (withUser context.Context) { + return context.WithValue(ctx, ctxKeyWebUser, u) +} + +// webUserFromContext returns the web user from the context, if any. +func webUserFromContext(ctx context.Context) (u *aghuser.User, ok bool) { + const key = ctxKeyWebUser + v := ctx.Value(key) + if v == nil { + return nil, false + } + + u, ok = v.(*aghuser.User) + if !ok { + panicBadType(key, v) + } + + return u, true +}