accounts and email
This commit is contained in:
@@ -18,13 +18,19 @@ const (
|
||||
sessionTTL = 24 * time.Hour
|
||||
challengeTTL = 5 * time.Minute
|
||||
deviceCodeTTL = 15 * time.Minute
|
||||
passwordResetTTL = time.Hour
|
||||
devicePollSeconds = 5
|
||||
)
|
||||
|
||||
type EmailSender interface {
|
||||
Send(ctx context.Context, to []string, subject, body string) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
webauthn *webauthn.WebAuthn
|
||||
publicOrigin string
|
||||
email EmailSender
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||
@@ -55,6 +61,10 @@ func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetEmailSender(sender EmailSender) {
|
||||
s.email = sender
|
||||
}
|
||||
|
||||
func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) {
|
||||
return s.repo.GetInstanceSettings(ctx)
|
||||
}
|
||||
@@ -119,7 +129,7 @@ func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAg
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
ok, err := verifyPassword(password, user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
if err != nil || !ok || user.Disabled {
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
principal, token, err := s.createSession(ctx, user, ip, userAgent)
|
||||
@@ -185,6 +195,9 @@ func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, str
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if user.Disabled {
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
assertion, session, err := s.webauthn.BeginLogin(user)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -205,6 +218,9 @@ func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r
|
||||
if err != nil {
|
||||
return Principal{}, "", err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
credential, err := s.webauthn.FinishLogin(user, session, r)
|
||||
if err != nil {
|
||||
return Principal{}, "", err
|
||||
@@ -224,6 +240,9 @@ func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Princ
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, fmt.Errorf("account disabled")
|
||||
}
|
||||
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil
|
||||
}
|
||||
|
||||
@@ -324,37 +343,129 @@ func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principa
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, fmt.Errorf("account disabled")
|
||||
}
|
||||
return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return User{}, fmt.Errorf("admin role required")
|
||||
}
|
||||
normalizedRole := Role(strings.ToLower(strings.TrimSpace(role)))
|
||||
switch normalizedRole {
|
||||
case RoleViewer, RoleEditor, RoleAdmin:
|
||||
default:
|
||||
return User{}, fmt.Errorf("invalid role")
|
||||
}
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if user.Role == RoleAdmin && normalizedRole != RoleAdmin {
|
||||
admins, err := s.repo.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if admins <= 1 {
|
||||
return User{}, fmt.Errorf("cannot demote the last admin account")
|
||||
}
|
||||
}
|
||||
if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil {
|
||||
users, err := s.UpdateUserAccess(ctx, actor, []UserAccessUpdate{{
|
||||
ID: userID,
|
||||
Role: Role(role),
|
||||
Disabled: user.Disabled,
|
||||
}})
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole})
|
||||
return s.repo.GetUserByID(ctx, user.ID)
|
||||
return users[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates []UserAccessUpdate) ([]User, error) {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return nil, fmt.Errorf("admin role required")
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil, fmt.Errorf("at least one user update is required")
|
||||
}
|
||||
normalized := make([]UserAccessUpdate, 0, len(updates))
|
||||
seen := make(map[string]struct{}, len(updates))
|
||||
for _, update := range updates {
|
||||
update.ID = strings.TrimSpace(update.ID)
|
||||
if update.ID == "" {
|
||||
return nil, fmt.Errorf("user id is required")
|
||||
}
|
||||
if _, ok := seen[update.ID]; ok {
|
||||
return nil, fmt.Errorf("duplicate user update")
|
||||
}
|
||||
seen[update.ID] = struct{}{}
|
||||
update.Role = Role(strings.ToLower(strings.TrimSpace(string(update.Role))))
|
||||
switch update.Role {
|
||||
case RoleViewer, RoleEditor, RoleAdmin:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid role")
|
||||
}
|
||||
normalized = append(normalized, update)
|
||||
}
|
||||
if err := s.repo.UpdateUserAccess(ctx, normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]User, 0, len(normalized))
|
||||
for _, update := range normalized {
|
||||
user, err := s.repo.GetUserByID(ctx, update.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.user.access.update", "user", user.ID, "", "", map[string]any{
|
||||
"role": user.Role,
|
||||
"disabled": user.Disabled,
|
||||
})
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error {
|
||||
if !Allows(actor, ScopeAdmin) {
|
||||
return fmt.Errorf("admin role required")
|
||||
}
|
||||
if s.email == nil {
|
||||
return fmt.Errorf("email sender is not configured")
|
||||
}
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret, err := randomToken(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := randomToken(18)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.repo.CreatePasswordResetToken(ctx, PasswordResetToken{
|
||||
ID: "reset:" + id,
|
||||
UserID: user.ID,
|
||||
TokenHash: hashSecret(secret),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(passwordResetTTL),
|
||||
InitiatedBy: actor.UserID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
link := s.publicOrigin + "/password-reset?token=" + url.QueryEscape(secret)
|
||||
body := fmt.Sprintf("A Cairnquire administrator requested a password reset for your account.\n\nSet a new password within one hour:\n%s\n\nIf you did not expect this message, contact your administrator.", link)
|
||||
if err := s.email.Send(ctx, []string{user.Email}, "Reset your Cairnquire password", body); err != nil {
|
||||
s.repo.ExpirePasswordResetToken(ctx, hashSecret(secret))
|
||||
return err
|
||||
}
|
||||
s.repo.Audit(ctx, actor.UserID, "auth.password.reset.request", "user", user.ID, "", "", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||||
if len(newPassword) < 12 {
|
||||
return fmt.Errorf("password must be at least 12 characters")
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return fmt.Errorf("password reset token is required")
|
||||
}
|
||||
passwordHash, err := hashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID, err := s.repo.CompletePasswordReset(ctx, hashSecret(token), passwordHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.repo.Audit(ctx, userID, "auth.password.reset.complete", "user", userID, "", "", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) {
|
||||
@@ -400,6 +511,9 @@ func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Princi
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return Principal{}, fmt.Errorf("account disabled")
|
||||
}
|
||||
expiresAt := time.Time{}
|
||||
if key.ExpiresAt != nil {
|
||||
expiresAt = *key.ExpiresAt
|
||||
@@ -491,6 +605,9 @@ func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (Create
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) {
|
||||
if user.Disabled {
|
||||
return Principal{}, "", fmt.Errorf("account disabled")
|
||||
}
|
||||
token, err := randomToken(32)
|
||||
if err != nil {
|
||||
return Principal{}, "", err
|
||||
|
||||
Reference in New Issue
Block a user