From 1cb6634d67208a208d090d2be8d18aa09207bafa Mon Sep 17 00:00:00 2001 From: Stanislav Chzhen Date: Mon, 7 Apr 2025 20:41:42 +0300 Subject: [PATCH] Pull request 2383: AGDNS-2743-aghuser Merge in DNS/adguard-home from AGDNS-2743-aghuser to master Squashed commit of the following: commit e3920df62be1625a3cfcc314a4aab3d1a378ca53 Merge: 70ce647f4 106785aab Author: Stanislav Chzhen Date: Mon Apr 7 19:44:15 2025 +0300 Merge branch 'master' into AGDNS-2743-aghuser commit 70ce647f47921f2bb34a561d63de2041f31e6bce Author: Stanislav Chzhen Date: Fri Apr 4 18:17:09 2025 +0300 aghuser: imp docs commit 87f6984248189de4a3dc0f2a245775141ea974d0 Author: Stanislav Chzhen Date: Wed Apr 2 19:03:03 2025 +0300 aghuser: imp code commit 636ecae85d1fce1657b5699a29451a9079d40222 Author: Stanislav Chzhen Date: Tue Apr 1 17:30:54 2025 +0300 all: add tests commit 5c842e94111123cf988332ccd1eb6754fa45585d Author: Stanislav Chzhen Date: Thu Mar 27 21:44:25 2025 +0300 all: aghuser --- internal/aghuser/aghuser.go | 58 ++++++++++++ internal/aghuser/aghuser_test.go | 6 ++ internal/aghuser/db.go | 149 +++++++++++++++++++++++++++++++ internal/aghuser/db_test.go | 83 +++++++++++++++++ internal/aghuser/user.go | 44 +++++++++ 5 files changed, 340 insertions(+) create mode 100644 internal/aghuser/aghuser.go create mode 100644 internal/aghuser/aghuser_test.go create mode 100644 internal/aghuser/db.go create mode 100644 internal/aghuser/db_test.go create mode 100644 internal/aghuser/user.go diff --git a/internal/aghuser/aghuser.go b/internal/aghuser/aghuser.go new file mode 100644 index 00000000..eed8617b --- /dev/null +++ b/internal/aghuser/aghuser.go @@ -0,0 +1,58 @@ +package aghuser + +import ( + "context" + + "github.com/AdguardTeam/golibs/errors" + "golang.org/x/crypto/bcrypt" +) + +// Login is the type for web user logins. +type Login string + +// NewLogin returns a web user login. +// +// TODO(s.chzhen): Add more constraints as needed. +func NewLogin(s string) (l Login, err error) { + if s == "" { + return "", errors.ErrEmptyValue + } + + return Login(s), nil +} + +// Password is an interface that defines methods for handling web user +// passwords. +type Password interface { + // Authenticate returns true if the provided password is allowed. + Authenticate(ctx context.Context, password string) (ok bool) + + // Hash returns a hashed representation of the web user password. + Hash() (b []byte) +} + +// DefaultPassword is the default bcrypt implementation of the [Password] +// interface. +type DefaultPassword struct { + hash []byte +} + +// NewDefaultPassword returns the new properly initialized *DefaultPassword. +func NewDefaultPassword(hash string) (p *DefaultPassword) { + return &DefaultPassword{ + hash: []byte(hash), + } +} + +// type check +var _ Password = (*DefaultPassword)(nil) + +// Authenticate implements the [Password] interface for *DefaultPassword. +func (p *DefaultPassword) Authenticate(ctx context.Context, passwd string) (ok bool) { + return bcrypt.CompareHashAndPassword([]byte(p.hash), []byte(passwd)) == nil +} + +// Hash implements the [Password] interface for *DefaultPassword. +func (p *DefaultPassword) Hash() (b []byte) { + return p.hash +} diff --git a/internal/aghuser/aghuser_test.go b/internal/aghuser/aghuser_test.go new file mode 100644 index 00000000..f9079705 --- /dev/null +++ b/internal/aghuser/aghuser_test.go @@ -0,0 +1,6 @@ +package aghuser_test + +import "time" + +// testTimeout is the common timeout for tests. +const testTimeout = 1 * time.Second diff --git a/internal/aghuser/db.go b/internal/aghuser/db.go new file mode 100644 index 00000000..48dd9b0a --- /dev/null +++ b/internal/aghuser/db.go @@ -0,0 +1,149 @@ +package aghuser + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "sync" + + "github.com/AdguardTeam/golibs/errors" +) + +// DB is an interface that defines methods for interacting with user +// information. All methods must be safe for concurrent use. +// +// TODO(s.chzhen): Use this. +// +// TODO(s.chzhen): Consider updating methods to return a clone. +type DB interface { + // All retrieves all users from the database, sorted by login. + // + // TODO(s.chzhen): Consider function signature change to reflect the + // in-memory implementation, as it currently always returns nil for error. + All(ctx context.Context) (users []*User, err error) + + // ByLogin retrieves a user by their login. u must not be modified. + // + // TODO(s.chzhen): Remove this once user sessions support [UserID]. + ByLogin(ctx context.Context, login Login) (u *User, err error) + + // ByUUID retrieves a user by their unique identifier. u must not be + // modified. + // + // TODO(s.chzhen): Use this. + ByUUID(ctx context.Context, id UserID) (u *User, err error) + + // Create adds a new user to the database. If the credentials already + // exist, it returns the [errors.ErrDuplicated] error. It also can return + // an error from the cryptographic randomness reader. u must not be + // modified. + Create(ctx context.Context, u *User) (err error) +} + +// DefaultDB is the default in-memory implementation of the [DB] interface. +type DefaultDB struct { + // mu protects all properties below. + mu *sync.Mutex + + // loginToUserID maps a web user login to their UserID. The values must not + // be empty. + // + // TODO(s.chzhen): Remove this once user sessions support [UserID]. + loginToUserID map[Login]UserID + + // userIDToUser maps a UserID to a web user. The values must not be nil. + // It must be synchronized with loginToUserID, meaning all UserIDs stored in + // loginToUserID must also be stored in this map. + userIDToUser map[UserID]*User +} + +// NewDefaultDB returns the new properly initialized *DefaultDB. +func NewDefaultDB() (db *DefaultDB) { + return &DefaultDB{ + mu: &sync.Mutex{}, + loginToUserID: map[Login]UserID{}, + userIDToUser: map[UserID]*User{}, + } +} + +// type check +var _ DB = (*DefaultDB)(nil) + +// All implements the [DB] interface for *DefaultDB. +func (db *DefaultDB) All(ctx context.Context) (users []*User, err error) { + db.mu.Lock() + defer db.mu.Unlock() + + if len(db.userIDToUser) == 0 { + return nil, nil + } + + users = slices.SortedStableFunc( + maps.Values(db.userIDToUser), + func(a, b *User) (res int) { + // TODO(s.chzhen): Consider adding a custom comparer. + return cmp.Compare(a.Login, b.Login) + }, + ) + + return users, nil +} + +// ByLogin implements the [DB] interface for *DefaultDB. +func (db *DefaultDB) ByLogin(ctx context.Context, login Login) (u *User, err error) { + db.mu.Lock() + defer db.mu.Unlock() + + id, ok := db.loginToUserID[login] + if !ok { + return nil, nil + } + + u, ok = db.userIDToUser[id] + if !ok { + // Should not happen. + panic(fmt.Errorf("no web user present with login %q", login)) + } + + return u, nil +} + +// ByUUID implements the [DB] interface for *DefaultDB. +func (db *DefaultDB) ByUUID(ctx context.Context, id UserID) (u *User, err error) { + db.mu.Lock() + defer db.mu.Unlock() + + u, ok := db.userIDToUser[id] + if !ok { + return nil, nil + } + + return u, nil +} + +// Create implements the [DB] interface for *DefaultDB. +func (db *DefaultDB) Create(ctx context.Context, u *User) (err error) { + db.mu.Lock() + defer db.mu.Unlock() + + if u.ID == (UserID{}) { + return fmt.Errorf("userid: %w", errors.ErrEmptyValue) + } + + _, ok := db.userIDToUser[u.ID] + if ok { + return fmt.Errorf("userid: %w", errors.ErrDuplicated) + } + + _, ok = db.loginToUserID[u.Login] + if ok { + return fmt.Errorf("login: %w", errors.ErrDuplicated) + } + + db.userIDToUser[u.ID] = u + db.loginToUserID[u.Login] = u.ID + + return nil +} diff --git a/internal/aghuser/db_test.go b/internal/aghuser/db_test.go new file mode 100644 index 00000000..077f7fb5 --- /dev/null +++ b/internal/aghuser/db_test.go @@ -0,0 +1,83 @@ +package aghuser_test + +import ( + "testing" + + "github.com/AdguardTeam/AdGuardHome/internal/aghuser" + "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestDB(t *testing.T) { + db := aghuser.NewDefaultDB() + + const ( + userWithIDPassRaw = "user_with_id_password" + userSecondPassRaw = "user_second_password" + ) + + userWithIDPassHash, err := bcrypt.GenerateFromPassword( + []byte(userWithIDPassRaw), + bcrypt.DefaultCost, + ) + require.NoError(t, err) + + userSecondPassHash, err := bcrypt.GenerateFromPassword( + []byte(userSecondPassRaw), + bcrypt.DefaultCost, + ) + require.NoError(t, err) + + userWithIDPass := aghuser.NewDefaultPassword(string(userWithIDPassHash)) + userSecondPass := aghuser.NewDefaultPassword(string(userSecondPassHash)) + + var ( + userWithID = &aghuser.User{ + ID: aghuser.MustNewUserID(), + Login: "user_with_id", + Password: userWithIDPass, + } + userSecond = &aghuser.User{ + ID: aghuser.MustNewUserID(), + Login: "user_second", + Password: userSecondPass, + } + userDuplicateLogin = &aghuser.User{ + ID: aghuser.MustNewUserID(), + Login: userWithID.Login, + Password: userWithIDPass, + } + ) + + ctx := testutil.ContextWithTimeout(t, testTimeout) + + err = db.Create(ctx, userWithID) + require.NoError(t, err) + + err = db.Create(ctx, userSecond) + require.NoError(t, err) + + err = db.Create(ctx, userDuplicateLogin) + assert.ErrorIs(t, err, errors.ErrDuplicated) + + got, err := db.ByUUID(ctx, userWithID.ID) + require.NoError(t, err) + + assert.Equal(t, userWithID, got) + assert.True(t, got.Password.Authenticate(ctx, userWithIDPassRaw)) + + got, err = db.ByLogin(ctx, userSecond.Login) + require.NoError(t, err) + + assert.Equal(t, userSecond, got) + assert.True(t, got.Password.Authenticate(ctx, userSecondPassRaw)) + + users, err := db.All(ctx) + require.NoError(t, err) + + assert.Len(t, users, 2) + assert.Equal(t, []*aghuser.User{userSecond, userWithID}, users) +} diff --git a/internal/aghuser/user.go b/internal/aghuser/user.go new file mode 100644 index 00000000..63dc4345 --- /dev/null +++ b/internal/aghuser/user.go @@ -0,0 +1,44 @@ +// Package aghuser contains types and logic for dealing with AdGuard Home's web +// users. +package aghuser + +import ( + "fmt" + + "github.com/google/uuid" +) + +// UserID is the type for the unique IDs of web users. +type UserID uuid.UUID + +// NewUserID returns a new web user unique identifier. Any error returned is an +// error from the cryptographic randomness reader. +func NewUserID() (uid UserID, err error) { + uuidv7, err := uuid.NewV7() + + return UserID(uuidv7), err +} + +// MustNewUserID is a wrapper around [NewUserID] that panics if there is an +// error. It is currently only used in tests. +func MustNewUserID() (uid UserID) { + uid, err := NewUserID() + if err != nil { + panic(fmt.Errorf("unexpected uuidv7 error: %w", err)) + } + + return uid +} + +// User represents a web user. +type User struct { + // ID is the unique identifier for the web user. It must not be empty. + ID UserID + + // Login is the login name of the web user. It must not be empty. + Login Login + + // Password stores the password information for the web user. It must not + // be nil. + Password Password +}