feat(PROJ-28): Self-Service Onboarding — Signup, Verify, Password Reset, Invites
- internal/mailer: SMTP-Out via net/smtp (TLS + STARTTLS), HTML+Text-Templates - internal/tokenstore: auth_tokens Tabelle, SHA-256-Hash, TTL, einmalig verwendbar - userstore: CreateInactive(), Activate(), GetByEmail(), SetPassword() - API: POST /signup, GET /verify, POST /forgot-password, POST /reset-password - API: POST /admin/invite (domain_admin+), GET /auth/invite?token (check) - Login-Seite: Links zu "Passwort vergessen" und "Registrieren" - Frontend: /signup, /verify, /forgot-password, /reset-password Seiten - server.fqdn nicht konfiguriert → Startup-Warnung, Self-Service deaktiviert - LDAP-Nutzer: Passwort-Reset abgewiesen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,57 @@ func (s *Store) Create(req CreateUserRequest) (*User, error) {
|
||||
return s.GetByID(id)
|
||||
}
|
||||
|
||||
// CreateInactive inserts a new local user with active=false (pending email verification).
|
||||
func (s *Store) CreateInactive(req CreateUserRequest) (*User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (username, email, password_hash, role, source, active, created_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, 'local', false, NOW(), $5)
|
||||
RETURNING id`,
|
||||
req.Username, req.Email, string(hash), req.Role, req.TenantID,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: create inactive: %w", err)
|
||||
}
|
||||
return s.GetByID(id)
|
||||
}
|
||||
|
||||
// Activate sets active=true for a user (called after email verification).
|
||||
func (s *Store) Activate(ctx context.Context, id int64) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE users SET active=true WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: activate: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("userstore: user %d not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by email address.
|
||||
func (s *Store) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE email = $1`, email,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// SetPassword updates the password hash for a user (used by password reset).
|
||||
func (s *Store) SetPassword(ctx context.Context, id int64, newPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("userstore: bcrypt: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `UPDATE users SET password_hash=$1 WHERE id=$2`, string(hash), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByID retrieves a user by their numeric ID.
|
||||
func (s *Store) GetByID(id int64) (*User, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user