package main import ( "context" "database/sql" "errors" "fmt" "log/slog" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/outbox" "atlas9.dev/c/core/tokens" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrEmailNotVerified = errors.New("email not verified") ErrUserExists = errors.New("user already exists") ErrNotLoggedIn = errors.New("not logged in") ) type Mailer interface { Send(ctx context.Context, email, subject, body string) error } type PasswordResetData struct { UserID core.ID } type IdentityApi struct { DB *sql.DB Users dbi.Factory[iam.UserStore] Passwords dbi.Factory[iam.PasswordStore] Profiles dbi.Factory[*ProfileStore] Tenants dbi.Factory[iam.TenantStore] TenantMembers dbi.Factory[iam.TenantMemberStore] Outbox dbi.Factory[outbox.Emitter] Sessions iam.SessionStore VerificationTokens tokens.Store[VerificationData] ResetTokens tokens.Store[PasswordResetData] Mailer Mailer BaseURL string } func (s *IdentityApi) Login(ctx context.Context, email, password string) (core.ID, error) { user, err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) (*iam.User, error) { users := s.Users(tx) passwords := s.Passwords(tx) user, err := users.GetByEmail(ctx, email) if err != nil { return nil, ErrInvalidCredentials } if err := iam.CheckPassword(ctx, passwords, user.ID, password); err != nil { return nil, ErrInvalidCredentials } return user, nil }) if err != nil { return core.ID{}, err } if !user.Verified { return core.ID{}, ErrEmailNotVerified } if err := s.Sessions.Put(ctx, user.ID); err != nil { return core.ID{}, fmt.Errorf("creating session: %w", err) } return user.ID, nil } func (s *IdentityApi) Register(ctx context.Context, email, password string) (core.ID, error) { userID, err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) (core.ID, error) { users := s.Users(tx) passwords := s.Passwords(tx) outbox := s.Outbox(tx) user := &iam.User{Email: email} created, err := users.Save(ctx, user) if err != nil { return core.ID{}, fmt.Errorf("saving user: %w", err) } if !created { return core.ID{}, ErrUserExists } if err := iam.SetPassword(ctx, passwords, user.ID, password); err != nil { return core.ID{}, fmt.Errorf("setting password: %w", err) } // Create personal tenant (tenant ID = user ID) tenant := &iam.Tenant{ ID: user.ID, Name: email, Type: iam.TenantPersonal, } if _, err := s.Tenants(tx).Save(ctx, tenant); err != nil { return core.ID{}, fmt.Errorf("creating personal tenant: %w", err) } if err := s.TenantMembers(tx).Add(ctx, user.ID, user.ID, iam.MemberActive); err != nil { return core.ID{}, fmt.Errorf("adding tenant membership: %w", err) } if err := EmitUserRegistered(ctx, outbox, user.ID, email); err != nil { return core.ID{}, fmt.Errorf("emitting registration event: %w", err) } return user.ID, nil }) if err != nil { return core.ID{}, err } return userID, nil } func (s *IdentityApi) Verify(ctx context.Context, combinedToken string) error { tok, err := s.VerificationTokens.Get(ctx, combinedToken) if err != nil { return fmt.Errorf("invalid or expired verification link: %w", err) } _, err = dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) (struct{}, error) { return struct{}{}, s.Users(tx).Verify(ctx, tok.Data.UserID) }) if err != nil { slog.Error("verifying user", "err", err) return fmt.Errorf("verifying user: %w", err) } _ = s.VerificationTokens.Delete(ctx, combinedToken) return nil } func (s *IdentityApi) ResendVerification(ctx context.Context, email string) error { user, err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) (*iam.User, error) { return s.Users(tx).GetByEmail(ctx, email) }) if err != nil { return ErrInvalidCredentials } if user.Verified { return nil } tok, err := s.VerificationTokens.Put(ctx, user.ID.String(), VerificationData{ UserID: user.ID, }) if err != nil { return fmt.Errorf("creating verification token: %w", err) } verifyURL := s.BaseURL + "/verify?token=" + tok.Key err = s.Mailer.Send(ctx, email, "Verify your email", "Click here to verify your email: "+verifyURL) if err != nil { return fmt.Errorf("sending verification email: %w", err) } return nil } func (s *IdentityApi) RequestPasswordReset(ctx context.Context, email string) error { user, err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) (*iam.User, error) { return s.Users(tx).GetByEmail(ctx, email) }) if err != nil { // Don't reveal whether the email exists return nil } tok, err := s.ResetTokens.Put(ctx, user.ID.String(), PasswordResetData{ UserID: user.ID, }) if err != nil { return fmt.Errorf("creating reset token: %w", err) } resetURL := s.BaseURL + "/reset-password?token=" + tok.Key err = s.Mailer.Send(ctx, email, "Reset your password", "Click here to reset your password: "+resetURL) if err != nil { return fmt.Errorf("sending reset email: %w", err) } return nil } func (s *IdentityApi) ResetPassword(ctx context.Context, combinedToken, newPassword string) error { tok, err := s.ResetTokens.Get(ctx, combinedToken) if err != nil { return fmt.Errorf("invalid or expired reset link: %w", err) } _, err = dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) (struct{}, error) { return struct{}{}, iam.SetPassword(ctx, s.Passwords(tx), tok.Data.UserID, newPassword) }) if err != nil { return fmt.Errorf("setting new password: %w", err) } _ = s.ResetTokens.Delete(ctx, combinedToken) return nil } func (s *IdentityApi) Logout(ctx context.Context) error { return s.Sessions.Destroy(ctx) } func (s *IdentityApi) GetProfile(ctx context.Context) (*iam.User, *Profile, error) { userID, _ := s.Sessions.Get(ctx) if userID.IsZero() { return nil, nil, ErrNotLoggedIn } type result struct { user *iam.User profile *Profile } res, err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) (result, error) { user, err := s.Users(tx).Get(ctx, userID) if err != nil { return result{}, err } profile, err := s.Profiles(tx).Get(ctx, userID) if err == core.ErrNotFound { profile = &Profile{UserID: userID} } else if err != nil { return result{}, err } return result{user, profile}, nil }) if err != nil { return nil, nil, err } return res.user, res.profile, nil } func (s *IdentityApi) GetUserByEmail(ctx context.Context, email string) (*iam.User, error) { return dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) (*iam.User, error) { return s.Users(tx).GetByEmail(ctx, email) }) } func (s *IdentityApi) ListUsers(ctx context.Context, page core.PageReq) (core.Page[iam.User], error) { return dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) (core.Page[iam.User], error) { return s.Users(tx).List(ctx, page) }) } func (s *IdentityApi) UpdateProfile(ctx context.Context, profile *Profile) error { userID, _ := s.Sessions.Get(ctx) if userID.IsZero() { return ErrNotLoggedIn } profile.UserID = userID _, err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) (struct{}, error) { return struct{}{}, s.Profiles(tx).Save(ctx, profile) }) return err }