feat(auth): add user profile, phone login, and account deletion
This commit is contained in:
@@ -26,23 +26,38 @@ func NewService(store Repository, tm *TokenManager) *Service {
|
||||
return &Service{store: store, tm: tm}
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, *TokenPair, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
// Register creates an account with at least one of email/phone. A missing
|
||||
// identifier is stored as NULL so phone-only accounts are possible.
|
||||
func (s *Service) Register(ctx context.Context, email, phone, password, name string) (*models.User, *TokenPair, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return nil, nil, httpx.BadRequest("invalid_email", "valid email is required")
|
||||
if name == "" {
|
||||
return nil, nil, httpx.BadRequest("invalid_name", "name is required")
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return nil, nil, httpx.BadRequest("weak_password", "password must be at least 8 characters")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, nil, httpx.BadRequest("invalid_name", "name is required")
|
||||
|
||||
normEmail, normPhone, err := s.normalizeIdentifiers(email, phone)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if normEmail == "" && normPhone == "" {
|
||||
return nil, nil, httpx.BadRequest("identifier_required", "email or phone is required")
|
||||
}
|
||||
|
||||
if _, err := s.store.GetUserByEmail(ctx, email); err == nil {
|
||||
return nil, nil, httpx.Conflict("email_taken", "email is already registered")
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, err
|
||||
if normEmail != "" {
|
||||
if _, err := s.store.GetUserByEmail(ctx, normEmail); err == nil {
|
||||
return nil, nil, httpx.Conflict("email_taken", "email is already registered")
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if normPhone != "" {
|
||||
if _, err := s.store.GetUserByPhone(ctx, normPhone); err == nil {
|
||||
return nil, nil, httpx.Conflict("phone_taken", "phone is already registered")
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
@@ -51,13 +66,14 @@ func (s *Service) Register(ctx context.Context, email, password, name string) (*
|
||||
}
|
||||
u := &models.User{
|
||||
ID: NewID(),
|
||||
Email: email,
|
||||
Email: normEmail,
|
||||
Phone: normPhone,
|
||||
PasswordHash: hash,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := s.store.CreateUser(ctx, u); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, mapIdentifierConflict(err)
|
||||
}
|
||||
|
||||
pair, err := s.issuePair(ctx, u)
|
||||
@@ -67,17 +83,27 @@ func (s *Service) Register(ctx context.Context, email, password, name string) (*
|
||||
return u, pair, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, email, password string) (*models.User, *TokenPair, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
u, err := s.store.GetUserByEmail(ctx, email)
|
||||
// Login authenticates by a single identifier — email or E.164 phone — and never
|
||||
// reveals which part of the credentials was wrong.
|
||||
func (s *Service) Login(ctx context.Context, identifier, password string) (*models.User, *TokenPair, error) {
|
||||
kind, value, err := NormalizeIdentifier(identifier)
|
||||
if err != nil {
|
||||
return nil, nil, invalidCredentials()
|
||||
}
|
||||
var u *models.User
|
||||
if kind == IdentifierEmail {
|
||||
u, err = s.store.GetUserByEmail(ctx, value)
|
||||
} else {
|
||||
u, err = s.store.GetUserByPhone(ctx, value)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
|
||||
return nil, nil, invalidCredentials()
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if !CheckPassword(u.PasswordHash, password) {
|
||||
return nil, nil, httpx.Unauthorized("invalid_credentials", "invalid email or password")
|
||||
return nil, nil, invalidCredentials()
|
||||
}
|
||||
pair, err := s.issuePair(ctx, u)
|
||||
if err != nil {
|
||||
@@ -120,6 +146,148 @@ func (s *Service) Logout(ctx context.Context, refreshToken string) error {
|
||||
return s.store.RevokeRefreshToken(ctx, t.ID)
|
||||
}
|
||||
|
||||
// UpdateName changes the display name, rejecting an empty one.
|
||||
func (s *Service) UpdateName(ctx context.Context, userID, name string) (*models.User, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, httpx.BadRequest("invalid_name", "name is required")
|
||||
}
|
||||
if err := s.store.UpdateName(ctx, userID, name); err != nil {
|
||||
return nil, userErr(err)
|
||||
}
|
||||
return s.store.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// ChangePassword verifies the current password and revokes every refresh token
|
||||
// so all sessions must log in again.
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID, current, newPassword string) error {
|
||||
if len(newPassword) < 8 {
|
||||
return httpx.BadRequest("weak_password", "password must be at least 8 characters")
|
||||
}
|
||||
u, err := s.store.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return userErr(err)
|
||||
}
|
||||
if !CheckPassword(u.PasswordHash, current) {
|
||||
return httpx.Unauthorized("current_password_incorrect", "current password is incorrect")
|
||||
}
|
||||
hash, err := HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpdatePasswordHash(ctx, userID, hash); err != nil {
|
||||
return userErr(err)
|
||||
}
|
||||
return s.store.RevokeAllUserTokens(ctx, userID)
|
||||
}
|
||||
|
||||
// AddIdentifier adds the one missing login identifier. Rejecting an attempt to
|
||||
// re-set an existing one or to provide both at once keeps identifiers immutable.
|
||||
func (s *Service) AddIdentifier(ctx context.Context, userID, email, phone string) (*models.User, error) {
|
||||
u, err := s.store.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, userErr(err)
|
||||
}
|
||||
normEmail, normPhone, err := s.normalizeIdentifiers(email, phone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normEmail == "" && normPhone == "" {
|
||||
return nil, httpx.BadRequest("identifier_required", "email or phone is required")
|
||||
}
|
||||
if normEmail != "" && normPhone != "" {
|
||||
return nil, httpx.BadRequest("invalid_request", "provide only one identifier at a time")
|
||||
}
|
||||
if u.Email != "" && u.Phone != "" {
|
||||
return nil, httpx.BadRequest("identifiers_complete", "both identifiers are already set")
|
||||
}
|
||||
if normEmail != "" {
|
||||
if u.Email != "" {
|
||||
return nil, httpx.BadRequest("email_already_set", "email can no longer be changed")
|
||||
}
|
||||
if _, err := s.store.GetUserByEmail(ctx, normEmail); err == nil {
|
||||
return nil, httpx.Conflict("email_taken", "email is already registered")
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if normPhone != "" {
|
||||
if u.Phone != "" {
|
||||
return nil, httpx.BadRequest("phone_already_set", "phone can no longer be changed")
|
||||
}
|
||||
if _, err := s.store.GetUserByPhone(ctx, normPhone); err == nil {
|
||||
return nil, httpx.Conflict("phone_taken", "phone is already registered")
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.store.AddIdentifier(ctx, userID, normEmail, normPhone); err != nil {
|
||||
return nil, mapIdentifierConflict(err)
|
||||
}
|
||||
return s.store.GetUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// DeleteAccount removes the account and everything that cascades from it after
|
||||
// confirming the password. It is immediate and irreversible.
|
||||
func (s *Service) DeleteAccount(ctx context.Context, userID, password string) error {
|
||||
u, err := s.store.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return userErr(err)
|
||||
}
|
||||
if !CheckPassword(u.PasswordHash, password) {
|
||||
return httpx.Unauthorized("current_password_incorrect", "password is incorrect")
|
||||
}
|
||||
if err := s.store.DeleteUser(ctx, userID); err != nil {
|
||||
return userErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeIdentifiers trims and canonicalises the provided email/phone. Empty
|
||||
// fields stay empty; supplied ones must be valid.
|
||||
func (s *Service) normalizeIdentifiers(email, phone string) (string, string, error) {
|
||||
var normEmail, normPhone string
|
||||
if e := strings.TrimSpace(email); e != "" {
|
||||
e = strings.ToLower(e)
|
||||
if !validEmail(e) {
|
||||
return "", "", httpx.BadRequest("invalid_email", "valid email is required")
|
||||
}
|
||||
normEmail = e
|
||||
}
|
||||
if p := strings.TrimSpace(phone); p != "" {
|
||||
np, err := NormalizePhone(p)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
normPhone = np
|
||||
}
|
||||
return normEmail, normPhone, nil
|
||||
}
|
||||
|
||||
func invalidCredentials() error {
|
||||
return httpx.Unauthorized("invalid_credentials", "invalid identifier or password")
|
||||
}
|
||||
|
||||
// userErr converts a missing user into an unauthorized/not-found style error
|
||||
// appropriate for authenticated profile operations.
|
||||
func userErr(err error) error {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.Unauthorized("unauthorized", "user not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func mapIdentifierConflict(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, ErrEmailTaken):
|
||||
return httpx.Conflict("email_taken", "email is already registered")
|
||||
case errors.Is(err, ErrPhoneTaken):
|
||||
return httpx.Conflict("phone_taken", "phone is already registered")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) issuePair(ctx context.Context, u *models.User) (*TokenPair, error) {
|
||||
access, err := s.tm.GenerateAccess(u.ID, u.Email)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user