Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 39 additions & 33 deletions internal/auth/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ type User struct {
// IsDisabled indicates if the user account is disabled.
// Disabled users cannot log in.
IsDisabled bool `json:"is_disabled,omitempty"`
// PasswordChangedAt records when the password was last changed.
// Tokens issued before this time are rejected. Nil means never changed.
PasswordChangedAt *time.Time `json:"password_changed_at,omitempty"`
}

// NewUser creates a User with a new UUID and sets CreatedAt and UpdatedAt to the current UTC time.
Expand All @@ -58,50 +61,53 @@ func NewUser(username string, passwordHash string, role Role) *User {
// UserForStorage is used for JSON serialization to persistent storage.
// It includes the password hash which is excluded from the regular User JSON.
type UserForStorage struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Role Role `json:"role"`
WorkspaceAccess *WorkspaceAccess `json:"workspace_access,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AuthProvider string `json:"auth_provider,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
IsDisabled bool `json:"is_disabled,omitempty"`
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Role Role `json:"role"`
WorkspaceAccess *WorkspaceAccess `json:"workspace_access,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AuthProvider string `json:"auth_provider,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
IsDisabled bool `json:"is_disabled,omitempty"`
PasswordChangedAt *time.Time `json:"password_changed_at,omitempty"`
}

// ToStorage converts a User to UserForStorage for persistence.
func (u *User) ToStorage() *UserForStorage {
return &UserForStorage{
ID: u.ID,
Username: u.Username,
PasswordHash: u.PasswordHash,
Role: u.Role,
WorkspaceAccess: CloneWorkspaceAccess(u.WorkspaceAccess),
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
AuthProvider: u.AuthProvider,
OIDCIssuer: u.OIDCIssuer,
OIDCSubject: u.OIDCSubject,
IsDisabled: u.IsDisabled,
ID: u.ID,
Username: u.Username,
PasswordHash: u.PasswordHash,
Role: u.Role,
WorkspaceAccess: CloneWorkspaceAccess(u.WorkspaceAccess),
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
AuthProvider: u.AuthProvider,
OIDCIssuer: u.OIDCIssuer,
OIDCSubject: u.OIDCSubject,
IsDisabled: u.IsDisabled,
PasswordChangedAt: u.PasswordChangedAt,
}
}

// ToUser converts UserForStorage back to User.
func (s *UserForStorage) ToUser() *User {
return &User{
ID: s.ID,
Username: s.Username,
PasswordHash: s.PasswordHash,
Role: s.Role,
WorkspaceAccess: CloneWorkspaceAccess(s.WorkspaceAccess),
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
AuthProvider: s.AuthProvider,
OIDCIssuer: s.OIDCIssuer,
OIDCSubject: s.OIDCSubject,
IsDisabled: s.IsDisabled,
ID: s.ID,
Username: s.Username,
PasswordHash: s.PasswordHash,
Role: s.Role,
WorkspaceAccess: CloneWorkspaceAccess(s.WorkspaceAccess),
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
AuthProvider: s.AuthProvider,
OIDCIssuer: s.OIDCIssuer,
OIDCSubject: s.OIDCSubject,
IsDisabled: s.IsDisabled,
PasswordChangedAt: s.PasswordChangedAt,
}
}

Expand Down
37 changes: 31 additions & 6 deletions internal/service/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ type Claims struct {
UserID string `json:"uid"`
Username string `json:"username"`
Role auth.Role `json:"role"`
// PasswordChangedAt is the Unix nanosecond timestamp of the user's last password
// change at token issuance. Zero means the user had never changed their password.
// Tokens issued before a subsequent password change are rejected.
PasswordChangedAt int64 `json:"pwd_changed_at_ns,omitempty"`
}

// Service provides authentication and user management functionality.
Expand Down Expand Up @@ -178,15 +182,20 @@ func (s *Service) GenerateToken(user *auth.User) (*TokenResult, error) {

now := time.Now()
expiresAt := now.Add(s.config.TokenTTL)
var pwdChangedAt int64
if user.PasswordChangedAt != nil {
pwdChangedAt = user.PasswordChangedAt.UnixNano()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
claims := &Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
UserID: user.ID,
Username: user.Username,
Role: user.Role,
UserID: user.ID,
Username: user.Username,
Role: user.Role,
PasswordChangedAt: pwdChangedAt,
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
Expand Down Expand Up @@ -250,6 +259,15 @@ func (s *Service) GetUserFromToken(ctx context.Context, tokenString string) (*au
return nil, ErrUserDisabled
}

// Reject tokens issued before the user's last password change.
// Zero claim (old tokens without the field) maps to epoch and is treated
// as "before any real password change", so changing password invalidates them.
if user.PasswordChangedAt != nil {
if claims.PasswordChangedAt < user.PasswordChangedAt.UnixNano() {
return nil, ErrInvalidToken
}
}

return user, nil
}

Expand Down Expand Up @@ -336,6 +354,8 @@ func (s *Service) UpdateUser(ctx context.Context, id string, input UpdateUserInp
return nil, err
}

now := time.Now().UTC()

if input.Password != nil && *input.Password != "" {
if err := s.validatePassword(*input.Password); err != nil {
return nil, err
Expand All @@ -345,13 +365,14 @@ func (s *Service) UpdateUser(ctx context.Context, id string, input UpdateUserInp
return nil, fmt.Errorf("failed to hash password: %w", err)
}
user.PasswordHash = string(passwordHash)
user.PasswordChangedAt = &now
}

if input.IsDisabled != nil {
user.IsDisabled = *input.IsDisabled
}

user.UpdatedAt = time.Now().UTC()
user.UpdatedAt = now

if err := s.store.Update(ctx, user); err != nil {
return nil, err
Expand Down Expand Up @@ -392,8 +413,10 @@ func (s *Service) ChangePassword(ctx context.Context, userID, oldPassword, newPa
return fmt.Errorf("failed to hash password: %w", err)
}

now := time.Now().UTC()
user.PasswordHash = string(passwordHash)
user.UpdatedAt = time.Now().UTC()
user.PasswordChangedAt = &now
user.UpdatedAt = now

return s.store.Update(ctx, user)
}
Expand All @@ -416,8 +439,10 @@ func (s *Service) ResetPassword(ctx context.Context, userID, newPassword string)
return fmt.Errorf("failed to hash password: %w", err)
}

now := time.Now().UTC()
user.PasswordHash = string(passwordHash)
user.UpdatedAt = time.Now().UTC()
user.PasswordChangedAt = &now
user.UpdatedAt = now

return s.store.Update(ctx, user)
}
Expand Down
105 changes: 105 additions & 0 deletions internal/service/auth/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,111 @@ func TestService_CreateUser_InvalidRole(t *testing.T) {
assert.Contains(t, err.Error(), "invalid role")
}

func TestService_GetUserFromToken_PasswordChangeInvalidatesToken(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()

ctx := context.Background()

user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleAdmin,
})
require.NoError(t, err)

// Issue token before password change.
tokenResult, err := svc.GenerateToken(user)
require.NoError(t, err)

// Token is valid before password change.
_, err = svc.GetUserFromToken(ctx, tokenResult.Token)
require.NoError(t, err, "token should be valid before password change")

// Change password — stamps PasswordChangedAt on the user record.
err = svc.ChangePassword(ctx, user.ID, "password123", "newpassword456")
require.NoError(t, err)

// Old token must now be rejected.
_, err = svc.GetUserFromToken(ctx, tokenResult.Token)
assert.ErrorIs(t, err, ErrInvalidToken, "old token must be rejected after password change")
}

func TestService_GetUserFromToken_NewTokenValidAfterPasswordChange(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()

ctx := context.Background()

user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleAdmin,
})
require.NoError(t, err)

err = svc.ChangePassword(ctx, user.ID, "password123", "newpassword456")
require.NoError(t, err)

// Fetch updated user (has PasswordChangedAt set) and issue fresh token.
updatedUser, err := svc.GetUser(ctx, user.ID)
require.NoError(t, err)

newToken, err := svc.GenerateToken(updatedUser)
require.NoError(t, err)

// New token must be accepted.
_, err = svc.GetUserFromToken(ctx, newToken.Token)
require.NoError(t, err, "token issued after password change should be valid")
}

func TestService_GetUserFromToken_ResetPasswordInvalidatesToken(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()

ctx := context.Background()

user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "victim",
Password: "password123",
Role: auth.RoleViewer,
})
require.NoError(t, err)

tokenResult, err := svc.GenerateToken(user)
require.NoError(t, err)

// Admin resets password.
err = svc.ResetPassword(ctx, user.ID, "adminreset789")
require.NoError(t, err)

// Old token must be rejected.
_, err = svc.GetUserFromToken(ctx, tokenResult.Token)
assert.ErrorIs(t, err, ErrInvalidToken, "token must be rejected after admin password reset")
}

func TestService_GetUserFromToken_NoPasswordChangeTokenStillValid(t *testing.T) {
svc, cleanup := setupTestService(t)
defer cleanup()

ctx := context.Background()

// User never changes password — PasswordChangedAt stays nil.
user, err := svc.CreateUser(ctx, CreateUserInput{
Username: "testuser",
Password: "password123",
Role: auth.RoleViewer,
})
require.NoError(t, err)

tokenResult, err := svc.GenerateToken(user)
require.NoError(t, err)

// Token must remain valid when password never changed.
_, err = svc.GetUserFromToken(ctx, tokenResult.Token)
require.NoError(t, err, "token should remain valid when user never changed password")
}

func setupTestServiceWithAPIKeys(t *testing.T) (*Service, func()) {
t.Helper()
backend := testutil.NewMemoryBackend()
Expand Down
17 changes: 14 additions & 3 deletions internal/service/frontend/api/v1/audit_permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,25 @@ func TestCommunityMode_ListUsersAndResetPassword(t *testing.T) {

// ResetUserPassword should succeed (no RBAC license required).
adminUserID := listResult.Users[0].Id
const newAdminPass = "newadminpass1"
server.Client().Post("/api/v1/users/"+adminUserID+"/reset-password", api.ResetPasswordRequest{
NewPassword: "newadminpass1",
NewPassword: newAdminPass,
}).WithBearerToken(adminToken).ExpectStatus(http.StatusOK).Send(t)

// CreateUser should fail — RBAC-gated.
// Re-authenticate with the new password — the old token is invalidated.
resp = server.Client().Post("/api/v1/auth/login", api.LoginRequest{
Username: "admin",
Password: newAdminPass,
}).ExpectStatus(http.StatusOK).Send(t)
var newLoginResult api.LoginResponse
resp.Unmarshal(t, &newLoginResult)
require.NotEmpty(t, newLoginResult.Token)
freshToken := newLoginResult.Token

// CreateUser should fail — RBAC-gated (community mode has no license for user management).
server.Client().Post("/api/v1/users", api.CreateUserRequest{
Username: "should-fail",
Password: "password123",
Role: api.UserRoleViewer,
}).WithBearerToken(adminToken).ExpectStatus(http.StatusForbidden).Send(t)
}).WithBearerToken(freshToken).ExpectStatus(http.StatusForbidden).Send(t)
}
Loading