645 lines
19 KiB
Go
645 lines
19 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
|
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
|
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
|
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
|
)
|
|
|
|
const (
|
|
// TokenLifetime is the access token duration (short-lived, requires refresh).
|
|
TokenLifetime = 15 * time.Minute
|
|
// SessionLifetime is how long a session stays valid before requiring re-login.
|
|
SessionLifetime = 30 * 24 * time.Hour // 30 days
|
|
// OTPExpiry is how long a one-time password is valid.
|
|
OTPExpiry = 10 * time.Minute
|
|
// MagicLinkExpiry is how long a magic link token is valid.
|
|
MagicLinkExpiry = 15 * time.Minute
|
|
// PasswordResetExpiry is how long a password reset token is valid.
|
|
PasswordResetExpiry = 1 * time.Hour
|
|
// EmailVerifyExpiry is how long an email verification code is valid.
|
|
EmailVerifyExpiry = 24 * time.Hour
|
|
)
|
|
|
|
// AuthService handles all authentication and identity flows.
|
|
type AuthService struct {
|
|
users port.UserRepository
|
|
sessions port.SessionRepository
|
|
codes port.AuthCodeRepository
|
|
email port.EmailSender
|
|
jwtSecret []byte
|
|
issuer string
|
|
registrationEnabled bool
|
|
logger *logging.Logger
|
|
}
|
|
|
|
// NewAuthService creates a new auth service.
|
|
func NewAuthService(
|
|
users port.UserRepository,
|
|
sessions port.SessionRepository,
|
|
codes port.AuthCodeRepository,
|
|
email port.EmailSender,
|
|
jwtSecret string,
|
|
registrationEnabled bool,
|
|
logger *logging.Logger,
|
|
) *AuthService {
|
|
return &AuthService{
|
|
users: users,
|
|
sessions: sessions,
|
|
codes: codes,
|
|
email: email,
|
|
jwtSecret: []byte(jwtSecret),
|
|
issuer: "persona-community-2",
|
|
registrationEnabled: registrationEnabled,
|
|
logger: logger.WithService("AuthService"),
|
|
}
|
|
}
|
|
|
|
// LoginOutput is the result of a successful login or registration.
|
|
type LoginOutput struct {
|
|
Token string
|
|
User *domain.User
|
|
}
|
|
|
|
// Register creates a new user account with email and password.
|
|
func (s *AuthService) Register(ctx context.Context, email, password, name, ip, userAgent string) (*LoginOutput, error) {
|
|
if !s.registrationEnabled {
|
|
return nil, domain.ErrRegistrationDisabled
|
|
}
|
|
|
|
if err := auth.ValidatePasswordStrength(password); err != nil {
|
|
return nil, fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
|
|
}
|
|
|
|
name = strings.TrimSpace(name)
|
|
if len(name) > domain.MaxNameLen {
|
|
return nil, domain.ErrNameTooLong
|
|
}
|
|
if len(email) > domain.MaxEmailLen {
|
|
return nil, domain.ErrEmailTooLong
|
|
}
|
|
|
|
exists, err := s.users.ExistsByEmail(ctx, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exists {
|
|
return nil, domain.ErrDuplicateEmail
|
|
}
|
|
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hashing password: %w", err)
|
|
}
|
|
|
|
userID := domain.UserID("usr_" + generateID())
|
|
user := domain.NewUser(userID, email, name)
|
|
|
|
if err := s.users.Create(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.users.SetPassword(ctx, userID, hash); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.logger.Info("user registered", "user_id", string(userID), "email", email)
|
|
|
|
return s.createSession(ctx, user, ip, userAgent)
|
|
}
|
|
|
|
// LoginWithPassword authenticates a user with email and password.
|
|
func (s *AuthService) LoginWithPassword(ctx context.Context, email, password, ip, userAgent string) (*LoginOutput, error) {
|
|
user, err := s.users.GetByEmail(ctx, email)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrUserNotFound) {
|
|
return nil, domain.ErrInvalidCredentials
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if user.Status == domain.UserStatusSuspended {
|
|
return nil, domain.ErrUserSuspended
|
|
}
|
|
|
|
hash, err := s.users.GetPasswordHash(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hash == "" || !auth.CheckPassword(password, hash) {
|
|
s.logger.Warn("invalid password attempt", "email", email)
|
|
return nil, domain.ErrInvalidCredentials
|
|
}
|
|
|
|
_ = s.users.UpdateLastLogin(ctx, user.ID)
|
|
s.logger.Info("user logged in", "user_id", string(user.ID), "email", email)
|
|
|
|
return s.createSession(ctx, user, ip, userAgent)
|
|
}
|
|
|
|
// RefreshToken issues a new access token if the session is still active.
|
|
func (s *AuthService) RefreshToken(ctx context.Context, sessionID string, userID string) (*LoginOutput, error) {
|
|
sid := domain.SessionID(sessionID)
|
|
session, err := s.sessions.Get(ctx, sid)
|
|
if err != nil {
|
|
return nil, domain.ErrSessionNotFound
|
|
}
|
|
if !session.IsActive() {
|
|
return nil, domain.ErrSessionRevoked
|
|
}
|
|
|
|
user, err := s.users.Get(ctx, domain.UserID(userID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user.Status == domain.UserStatusSuspended {
|
|
return nil, domain.ErrUserSuspended
|
|
}
|
|
|
|
_ = s.sessions.UpdateLastActive(ctx, sid)
|
|
|
|
token, err := s.generateToken(user, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &LoginOutput{Token: token, User: user}, nil
|
|
}
|
|
|
|
// Logout revokes the current session.
|
|
func (s *AuthService) Logout(ctx context.Context, sessionID string) error {
|
|
if sessionID == "" {
|
|
return nil
|
|
}
|
|
return s.sessions.Revoke(ctx, domain.SessionID(sessionID))
|
|
}
|
|
|
|
// LogoutAll revokes all sessions for a user, optionally keeping one.
|
|
func (s *AuthService) LogoutAll(ctx context.Context, userID string, exceptSessionID *string) error {
|
|
var except *domain.SessionID
|
|
if exceptSessionID != nil {
|
|
sid := domain.SessionID(*exceptSessionID)
|
|
except = &sid
|
|
}
|
|
return s.sessions.RevokeAllForUser(ctx, domain.UserID(userID), except)
|
|
}
|
|
|
|
// CheckSession returns whether a session is active (not revoked, not expired).
|
|
// Used as auth.SessionChecker for the SessionCheck middleware.
|
|
func (s *AuthService) CheckSession(ctx context.Context, sessionID string) (bool, error) {
|
|
session, err := s.sessions.Get(ctx, domain.SessionID(sessionID))
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return session.IsActive(), nil
|
|
}
|
|
|
|
// ListSessions returns all active sessions for a user.
|
|
func (s *AuthService) ListSessions(ctx context.Context, userID string) ([]domain.Session, error) {
|
|
return s.sessions.ListByUser(ctx, domain.UserID(userID))
|
|
}
|
|
|
|
// RevokeSession revokes a specific session for a user.
|
|
func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID string) error {
|
|
session, err := s.sessions.Get(ctx, domain.SessionID(sessionID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if session.UserID != domain.UserID(userID) {
|
|
return domain.ErrSessionNotFound
|
|
}
|
|
return s.sessions.Revoke(ctx, domain.SessionID(sessionID))
|
|
}
|
|
|
|
// GetCurrentUser returns the full user for the given ID.
|
|
func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*domain.User, error) {
|
|
return s.users.Get(ctx, domain.UserID(userID))
|
|
}
|
|
|
|
// UpdateProfile updates a user's name and avatar.
|
|
func (s *AuthService) UpdateProfile(ctx context.Context, userID, name, avatarURL string) (*domain.User, error) {
|
|
user, err := s.users.Get(ctx, domain.UserID(userID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if name != "" {
|
|
name = strings.TrimSpace(name)
|
|
if len(name) > domain.MaxNameLen {
|
|
return nil, domain.ErrNameTooLong
|
|
}
|
|
user.Name = name
|
|
}
|
|
if avatarURL != "" {
|
|
if err := validateAvatarURL(avatarURL); err != nil {
|
|
return nil, err
|
|
}
|
|
user.AvatarURL = avatarURL
|
|
}
|
|
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// ChangePassword changes a user's password after verifying the current one.
|
|
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
|
|
uid := domain.UserID(userID)
|
|
|
|
hash, err := s.users.GetPasswordHash(ctx, uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hash == "" || !auth.CheckPassword(currentPassword, hash) {
|
|
return domain.ErrInvalidCredentials
|
|
}
|
|
|
|
if err := auth.ValidatePasswordStrength(newPassword); err != nil {
|
|
return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
|
|
}
|
|
|
|
newHash, err := auth.HashPassword(newPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("hashing password: %w", err)
|
|
}
|
|
|
|
return s.users.SetPassword(ctx, uid, newHash)
|
|
}
|
|
|
|
// SendOTP generates a one-time password for the given email.
|
|
// If the email is not registered and registration is enabled, the code is still
|
|
// sent — the account will be created when the code is verified. This supports a
|
|
// unified register+login flow with a single OTP email.
|
|
func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error {
|
|
user, err := s.users.GetByEmail(ctx, email)
|
|
if err != nil {
|
|
if !errors.Is(err, domain.ErrUserNotFound) {
|
|
return err
|
|
}
|
|
// Unknown email: only proceed if registration is open.
|
|
if !s.registrationEnabled {
|
|
s.logger.Info("OTP requested for unknown email (registration disabled)", "email", email)
|
|
return nil
|
|
}
|
|
// Registration enabled — send code anyway. UserID will be nil until verify.
|
|
user = nil
|
|
}
|
|
|
|
code := generateOTP()
|
|
var uid *domain.UserID
|
|
if user != nil {
|
|
uid = &user.ID
|
|
}
|
|
authCode := &domain.AuthCode{
|
|
ID: "acd_" + generateID(),
|
|
UserID: uid,
|
|
Email: email,
|
|
Code: code,
|
|
Purpose: domain.PurposeLoginOTP,
|
|
ExpiresAt: time.Now().Add(OTPExpiry),
|
|
IPAddress: ip,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.codes.Create(ctx, authCode); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("auth code created", "purpose", "login_otp", "email", email, "code_id", authCode.ID)
|
|
if err := s.email.SendAuthCode(ctx, email, code, string(domain.PurposeLoginOTP)); err != nil {
|
|
s.logger.Error("failed to send OTP email", "email", email, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VerifyOTP verifies a one-time password and returns a login token.
|
|
// If the email has no account yet and registration is enabled, the account is
|
|
// created automatically — OTP delivery proves email ownership.
|
|
func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent string) (*LoginOutput, error) {
|
|
authCode, err := s.codes.FindValid(ctx, email, code, domain.PurposeLoginOTP)
|
|
if err != nil {
|
|
return nil, domain.ErrInvalidAuthCode
|
|
}
|
|
|
|
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := s.users.GetByEmail(ctx, email)
|
|
if err != nil {
|
|
if !errors.Is(err, domain.ErrUserNotFound) {
|
|
return nil, err
|
|
}
|
|
if !s.registrationEnabled {
|
|
return nil, domain.ErrRegistrationDisabled
|
|
}
|
|
// Auto-register: OTP delivery already proved email ownership.
|
|
user, err = s.autoRegisterViaOTP(ctx, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
_ = s.users.UpdateLastLogin(ctx, user.ID)
|
|
s.logger.Info("user logged in via OTP", "user_id", string(user.ID), "email", email)
|
|
|
|
return s.createSession(ctx, user, ip, userAgent)
|
|
}
|
|
|
|
// autoRegisterViaOTP creates a minimal user account for an email that just
|
|
// verified an OTP. Email is considered verified because OTP delivery proved
|
|
// ownership. The name defaults to the local part of the email address.
|
|
func (s *AuthService) autoRegisterViaOTP(ctx context.Context, email string) (*domain.User, error) {
|
|
name := email
|
|
if at := strings.IndexByte(email, '@'); at > 0 {
|
|
name = email[:at]
|
|
}
|
|
userID := domain.UserID("usr_" + generateID())
|
|
user := domain.NewUser(userID, email, name)
|
|
user.EmailVerified = true // OTP delivery proves ownership
|
|
|
|
if err := s.users.Create(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
s.logger.Info("user auto-registered via OTP", "user_id", string(userID), "email", email)
|
|
return user, nil
|
|
}
|
|
|
|
// SendMagicLink generates and logs a magic link token.
|
|
func (s *AuthService) SendMagicLink(ctx context.Context, email, ip string) error {
|
|
// Magic links can work for existing users.
|
|
// Don't reveal whether email exists — but propagate infrastructure errors.
|
|
user, err := s.users.GetByEmail(ctx, email)
|
|
if err != nil && !errors.Is(err, domain.ErrUserNotFound) {
|
|
return err
|
|
}
|
|
|
|
token := generateHexToken()
|
|
var uid *domain.UserID
|
|
if user != nil {
|
|
uid = &user.ID
|
|
}
|
|
|
|
authCode := &domain.AuthCode{
|
|
ID: "acd_" + generateID(),
|
|
UserID: uid,
|
|
Email: email,
|
|
Code: token,
|
|
Purpose: domain.PurposeMagicLink,
|
|
ExpiresAt: time.Now().Add(MagicLinkExpiry),
|
|
IPAddress: ip,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.codes.Create(ctx, authCode); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("auth code created", "purpose", "magic_link", "email", email, "code_id", authCode.ID)
|
|
if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposeMagicLink)); err != nil {
|
|
s.logger.Error("failed to send magic link email", "email", email, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VerifyMagicLink verifies a magic link token and returns a login token.
|
|
func (s *AuthService) VerifyMagicLink(ctx context.Context, email, token, ip, userAgent string) (*LoginOutput, error) {
|
|
authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposeMagicLink)
|
|
if err != nil {
|
|
return nil, domain.ErrInvalidAuthCode
|
|
}
|
|
|
|
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := s.users.GetByEmail(ctx, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = s.users.UpdateLastLogin(ctx, user.ID)
|
|
s.logger.Info("user logged in via magic link", "user_id", string(user.ID), "email", email)
|
|
|
|
return s.createSession(ctx, user, ip, userAgent)
|
|
}
|
|
|
|
// ForgotPassword generates a password reset token.
|
|
func (s *AuthService) ForgotPassword(ctx context.Context, email, ip string) error {
|
|
user, err := s.users.GetByEmail(ctx, email)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrUserNotFound) {
|
|
// Don't reveal whether email exists
|
|
s.logger.Info("password reset requested for unknown email", "email", email)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
token := generateHexToken()
|
|
uid := user.ID
|
|
authCode := &domain.AuthCode{
|
|
ID: "acd_" + generateID(),
|
|
UserID: &uid,
|
|
Email: email,
|
|
Code: token,
|
|
Purpose: domain.PurposePasswordReset,
|
|
ExpiresAt: time.Now().Add(PasswordResetExpiry),
|
|
IPAddress: ip,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.codes.Create(ctx, authCode); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("auth code created", "purpose", "password_reset", "email", email, "code_id", authCode.ID)
|
|
if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposePasswordReset)); err != nil {
|
|
s.logger.Error("failed to send password reset email", "email", email, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResetPassword sets a new password using a reset token and revokes all sessions.
|
|
func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error {
|
|
authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposePasswordReset)
|
|
if err != nil {
|
|
return domain.ErrInvalidAuthCode
|
|
}
|
|
|
|
if err := auth.ValidatePasswordStrength(newPassword); err != nil {
|
|
return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
|
|
}
|
|
|
|
user, err := s.users.GetByEmail(ctx, email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hash, err := auth.HashPassword(newPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("hashing password: %w", err)
|
|
}
|
|
|
|
if err := s.users.SetPassword(ctx, user.ID, hash); err != nil {
|
|
return err
|
|
}
|
|
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Revoke all sessions — user must re-login with new password.
|
|
_ = s.sessions.RevokeAllForUser(ctx, user.ID, nil)
|
|
s.logger.Info("password reset completed", "user_id", string(user.ID), "email", email)
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendVerifyEmail generates an email verification code.
|
|
func (s *AuthService) SendVerifyEmail(ctx context.Context, userID string) error {
|
|
user, err := s.users.Get(ctx, domain.UserID(userID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user.EmailVerified {
|
|
return nil
|
|
}
|
|
|
|
code := generateOTP()
|
|
uid := user.ID
|
|
authCode := &domain.AuthCode{
|
|
ID: "acd_" + generateID(),
|
|
UserID: &uid,
|
|
Email: user.Email,
|
|
Code: code,
|
|
Purpose: domain.PurposeEmailVerify,
|
|
ExpiresAt: time.Now().Add(EmailVerifyExpiry),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.codes.Create(ctx, authCode); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("auth code created", "purpose", "email_verify", "email", user.Email, "code_id", authCode.ID)
|
|
if err := s.email.SendAuthCode(ctx, user.Email, code, string(domain.PurposeEmailVerify)); err != nil {
|
|
s.logger.Error("failed to send email verification", "email", user.Email, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VerifyEmail marks the user's email as verified.
|
|
func (s *AuthService) VerifyEmail(ctx context.Context, userID, code string) error {
|
|
user, err := s.users.Get(ctx, domain.UserID(userID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
authCode, err := s.codes.FindValid(ctx, user.Email, code, domain.PurposeEmailVerify)
|
|
if err != nil {
|
|
return domain.ErrInvalidAuthCode
|
|
}
|
|
|
|
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
user.EmailVerified = true
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("email verified", "user_id", userID, "email", user.Email)
|
|
return nil
|
|
}
|
|
|
|
// createSession creates a session record and generates a JWT.
|
|
func (s *AuthService) createSession(ctx context.Context, user *domain.User, ip, userAgent string) (*LoginOutput, error) {
|
|
sessionID := "ses_" + generateID()
|
|
now := time.Now()
|
|
|
|
session := &domain.Session{
|
|
ID: domain.SessionID(sessionID),
|
|
UserID: user.ID,
|
|
IPAddress: ip,
|
|
UserAgent: userAgent,
|
|
DeviceLabel: auth.ParseDeviceLabel(userAgent),
|
|
LastActiveAt: now,
|
|
ExpiresAt: now.Add(SessionLifetime),
|
|
CreatedAt: now,
|
|
}
|
|
|
|
if err := s.sessions.Create(ctx, session); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
token, err := s.generateToken(user, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &LoginOutput{Token: token, User: user}, nil
|
|
}
|
|
|
|
// generateToken creates a JWT for the user with the given session ID.
|
|
func (s *AuthService) generateToken(user *domain.User, sessionID string) (string, error) {
|
|
authUser := &auth.User{
|
|
ID: string(user.ID),
|
|
Email: user.Email,
|
|
Roles: user.Roles,
|
|
}
|
|
return auth.GenerateTokenWithSession(
|
|
s.jwtSecret, authUser, TokenLifetime, s.issuer, s.issuer, sessionID,
|
|
)
|
|
}
|
|
|
|
// generateID returns a random hex string suitable for entity IDs.
|
|
func generateID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// generateOTP returns a 6-digit numeric one-time password.
|
|
func generateOTP() string {
|
|
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
|
if err != nil {
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return fmt.Sprintf("%06d", n.Int64())
|
|
}
|
|
|
|
// validateAvatarURL checks that the URL uses http or https.
|
|
func validateAvatarURL(rawURL string) error {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return domain.ErrInvalidAvatarURL
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return domain.ErrInvalidAvatarURL
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generateHexToken returns a 32-character hex token for magic links and resets.
|
|
func generateHexToken() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|