package api import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/tokens" "atlas9.dev/c/demo/lib" "atlas9.dev/c/demo/lib/users" "atlas9.dev/c/demo/store" "atlas9.dev/c/demo/tasks" ) type AccountImpl struct { DB *sql.DB Users dbi.Factory[iam.UserStore] Passwords dbi.Factory[iam.PasswordStore] Tasks dbi.Factory[tasks.Producer] Provisioner users.Provisioner EmailVerificationTokens dbi.Factory[store.EmailVerificationTokenStore] PasswordResetTokens dbi.Factory[store.PasswordResetTokenStore] Throttle *AccountThrottle } func (s *AccountImpl) Register(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Read and validate the request body var req Account_RegisterReq if read(w, r, &req) { return } if !s.Throttle.Check(ctx, req.Email) { write(ctx, w, ErrThrottle, nil) return } // TODO interesting case of the system needing access ctx = lib.PutSystemAccess(ctx, iam.CapUsersGetByEmail, iam.CapUsersSave, iam.CapPasswordsSet, ) var res Account_RegisterRes err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) passwords := s.Passwords(tx) // TODO tasks and tokens should have guards too tokens := s.EmailVerificationTokens(tx) tasks := s.Tasks(tx) if err := ensureEmailDoesNotExist(ctx, users, req.Email); err != nil { return err } // TODO maybe don't even create a user record yet. // at this point, it's only a self-invite. user := iam.NewUser(req.Email) if err := users.Save(ctx, &user); err != nil { return fmt.Errorf("saving user: %w", err) } // TODO verify email before setting password? if err := iam.SetPassword(ctx, passwords, user.ID, req.Password); err != nil { return fmt.Errorf("setting password: %w", err) } if err := createAndSendEmailVerfication(ctx, tokens, tasks, &user); err != nil { return err } res.UserID = user.ID return nil }) write(ctx, w, err, res) } func ensureEmailDoesNotExist(ctx context.Context, users iam.UserStore, email string) error { var existing iam.User err := users.GetByEmail(ctx, email, &existing) if err == nil { return ErrUserExists } if !errors.Is(err, core.ErrNotFound) { return err } return nil } func createAndSendEmailVerfication( ctx context.Context, tokens store.EmailVerificationTokenStore, producer tasks.Producer, user *iam.User, ) error { tok, err := tokens.Create(ctx, store.EmailVerificationToken{UserID: user.ID}) if err != nil { return fmt.Errorf("creating email verification token: %w", err) } payload, err := json.Marshal(store.EmailVerificationTask{ UserID: user.ID, Email: user.Email, Token: tok.Key, }) if err != nil { return fmt.Errorf("marshaling email verification task: %w", err) } if err := producer.Push(ctx, "Tasks.SendEmailVerification", payload); err != nil { return fmt.Errorf("creating email verification task: %w", err) } return nil } func (s *AccountImpl) Verify(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req Account_VerifyReq if read(w, r, &req) { return } // TODO interesting case of the system needing access ctx := lib.PutSystemAccess(r.Context(), iam.CapUsersSave, iam.CapUsersGet, iam.CapUsersVerify, ) err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) // Verify the token tok, err := tokens.SplitAndVerify(ctx, s.EmailVerificationTokens(tx), req.Token) if err != nil { return fmt.Errorf("invalid request token: %w", err) } userID := tok.Data.UserID var user iam.User if err := users.Get(ctx, userID, &user); err != nil { return err } // Provision user if err := s.Provisioner.Provision(ctx, tx, &user); err != nil { return err } // Verify user if err := users.Verify(ctx, userID); err != nil { return fmt.Errorf("verifying user: %w", err) } return nil }) write(ctx, w, err, nil) } func (s *AccountImpl) ResendVerification(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req Account_ResendVerificationReq if read(w, r, &req) { return } ctx := r.Context() if !s.Throttle.Check(ctx, req.Email) { write(ctx, w, ErrThrottle, nil) return } err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) tokens := s.EmailVerificationTokens(tx) tasks := s.Tasks(tx) var user iam.User if err := users.GetByEmail(ctx, req.Email, &user); err != nil { return err } if user.Verified { return nil } if err := createAndSendEmailVerfication(ctx, tokens, tasks, &user); err != nil { return err } return nil }) write(ctx, w, err, nil) } func (s *AccountImpl) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req Account_RequestPasswordResetReq if read(w, r, &req) { return } ctx := r.Context() ctx = lib.PutSystemAccess(ctx, iam.CapUsersGetByEmail) if !s.Throttle.Check(ctx, req.Email) { write(ctx, w, ErrThrottle, nil) return } err := dbi.ReadOnly(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) tokens := s.PasswordResetTokens(tx) tasks := s.Tasks(tx) var user iam.User if err := users.GetByEmail(ctx, req.Email, &user); err != nil { return fmt.Errorf("getting user by email: %w", err) } tok, err := tokens.Create(ctx, store.PasswordResetData{ UserID: user.ID, }) if err != nil { return fmt.Errorf("creating reset token: %w", err) } payload, err := json.Marshal(store.PasswordResetTaskData{ UserID: user.ID, Email: user.Email, Token: tok.Combined(), }) if err != nil { return fmt.Errorf("marshaling password reset task: %w", err) } if err := tasks.Push(ctx, "Tasks.SendPasswordReset", payload); err != nil { return fmt.Errorf("creating task: %w", err) } return nil }) if err != nil { slog.ErrorContext(ctx, "requesting password reset", "error", err) } // Don't return an error, which could reveal whether the email exists write(ctx, w, nil, nil) } func (s *AccountImpl) ResetPassword(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req Account_ResetPasswordReq if read(w, r, &req) { return } ctx := r.Context() err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { // Verify the token tok, err := tokens.SplitAndVerify(ctx, s.PasswordResetTokens(tx), req.Token) if err != nil { return err } // Set the password return iam.SetPassword( lib.PutSystemAccess(ctx, iam.CapPasswordsSet), s.Passwords(tx), tok.Data.UserID, req.Password, ) }) write(ctx, w, err, nil) }