package api_impl import ( "context" "database/sql" "encoding/json" "errors" "fmt" "html" "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/api" "atlas9.dev/c/demo/lib/access" "atlas9.dev/c/demo/lib/users" "atlas9.dev/c/demo/store" "atlas9.dev/c/demo/tasks" "atlas9.dev/c/mail" ) 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] EmailVerificationTasks dbi.Factory[*store.EmailVerificationTaskStore] PasswordResetTokens dbi.Factory[store.PasswordResetTokenStore] Audit dbi.Factory[iam.AuditStore] Throttle *AccountThrottle Mailer mail.Mailer BaseURL string } func (s *AccountImpl) ServeMux(mux *http.ServeMux) { mux.HandleFunc(api.Path_Account_Register, s.Register) mux.HandleFunc(api.Path_Account_Verify, s.Verify) mux.HandleFunc(api.Path_Account_RequestPasswordReset, s.RequestPasswordReset) mux.HandleFunc(api.Path_Account_ResendVerification, s.ResendVerification) mux.HandleFunc(api.Path_Account_ResetPassword, s.ResetPassword) } // ServeTasksMux registers the task endpoints separately, because they must // not be on the public mux with the rest of the Account namespace: they are // called by the task workers, which authenticate as a system principal. func (s *AccountImpl) ServeTasksMux(mux *http.ServeMux) { mux.HandleFunc(api.Path_Account_Task_SendEmailVerification, s.SendEmailVerification) mux.HandleFunc(api.Path_Account_Task_SendPasswordReset, s.SendPasswordReset) } func (s *AccountImpl) SendEmailVerification(w http.ResponseWriter, r *http.Request) { var req api.Account_Task_SendEmailVerificationReq if read(w, r, &req) { return } verifyURL := s.BaseURL + "/verify?token=" + req.Token err := s.Mailer.Send(r.Context(), req.Email, mail.Content{ Subject: "Verify your email", TextBody: "Click here to verify your email: " + verifyURL, HtmlBody: fmt.Sprintf( `

Click here to verify your email.

`, html.EscapeString(verifyURL), ), }) write(r.Context(), w, err, nil) } func (s *AccountImpl) SendPasswordReset(w http.ResponseWriter, r *http.Request) { var req api.Account_Task_SendPasswordResetReq if read(w, r, &req) { return } ctx := r.Context() resetURL := s.BaseURL + "/reset-password?token=" + req.Token text := "Click here to reset your password: " + resetURL htmlBody := fmt.Sprintf( `

Click here to reset your password.

`, html.EscapeString(resetURL), ) err := s.Mailer.Send(ctx, req.Email, mail.Content{ Subject: "Reset your password", TextBody: text, HtmlBody: htmlBody, }) write(r.Context(), w, err, nil) } func (s *AccountImpl) Register(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Read and validate the request body. var req api.Account_RegisterReq if read(w, r, &req) { return } // Throttle account actions by email. if !s.Throttle.Check(ctx, req.Email) { write(ctx, w, api.ErrThrottle, nil) return } // Grant this operation the access it needs. // TODO interesting case of the system needing access ctx = access.PutSystem(ctx, iam.CapUsersGetByEmail, iam.CapUsersSave, iam.CapPasswordsSet, ) // Write to the database. var res api.Account_RegisterRes err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) passwords := s.Passwords(tx) // TODO tokens should have guards too tokens := s.EmailVerificationTokens(tx) tasks := s.EmailVerificationTasks(tx) if err := ensureEmailDoesNotExist(ctx, users, req.Email); err != nil { return err } // TODO don't 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) } // Create an email verification token. tok, err := tokens.Create(ctx, store.EmailVerificationToken{UserID: user.ID}) if err != nil { return fmt.Errorf("creating token: %w", err) } // Queue a task to send the verification email. err = tasks.Create(ctx, store.EmailVerificationTask{ UserID: user.ID, Email: user.Email, Token: tok.Combined(), }) if err != nil { return fmt.Errorf("creating task: %w", err) } res.UserID = user.ID return audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: user.ID, Action: "Account_Register", Resource: user.Email, }) }) 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 api.ErrUserExists } if !errors.Is(err, core.ErrNotFound) { return err } return nil } func (s *AccountImpl) Verify(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req api.Account_VerifyReq if read(w, r, &req) { return } // TODO interesting case of the system needing access ctx := access.PutSystem(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 { // TODO this ends up returning a 404, which I always find confusing (is the route a 404 or a resource). // since this API is not resource-oriented, this should be an error, not a 404. 404 should be for // route not found only. 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 audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: userID, Action: "Account_Verify", Resource: user.Email, }) }) write(ctx, w, err, nil) } func (s *AccountImpl) ResendVerification(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req api.Account_ResendVerificationReq if read(w, r, &req) { return } ctx := r.Context() // Throttle account actions by email. if !s.Throttle.Check(ctx, req.Email) { write(ctx, w, api.ErrThrottle, nil) return } // Grant this operation the access it needs. // TODO interesting case of the system needing access ctx = access.PutSystem(ctx, iam.CapUsersGetByEmail) // Write to the database. err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { users := s.Users(tx) tokens := s.EmailVerificationTokens(tx) tasks := s.EmailVerificationTasks(tx) var user iam.User if err := users.GetByEmail(ctx, req.Email, &user); err != nil { return err } if user.Verified { return nil } // Create an email verification token. tok, err := tokens.Create(ctx, store.EmailVerificationToken{UserID: user.ID}) if err != nil { return fmt.Errorf("creating email verification token: %w", err) } // Queue a task to send the verification email. err = tasks.Create(ctx, store.EmailVerificationTask{ UserID: user.ID, Email: user.Email, Token: tok.Combined(), }) if err != nil { return err } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: user.ID, Action: "Account_ResendVerification", Resource: user.Email, }) }) write(ctx, w, err, nil) } func (s *AccountImpl) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req api.Account_RequestPasswordResetReq if read(w, r, &req) { return } ctx := r.Context() ctx = access.PutSystem(ctx, iam.CapUsersGetByEmail) if !s.Throttle.Check(ctx, req.Email) { write(ctx, w, api.ErrThrottle, nil) return } // This flow writes tokens, a task, and an audit entry, so it needs a // read-write transaction (it previously ran under ReadOnly). err := dbi.ReadWrite(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, api.Path_Account_Task_SendPasswordReset, payload); err != nil { return fmt.Errorf("creating task: %w", err) } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: user.ID, Action: "Account_RequestPasswordReset", Resource: user.Email, }) }) 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 api.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 err = iam.SetPassword( access.PutSystem(ctx, iam.CapPasswordsSet), s.Passwords(tx), tok.Data.UserID, req.Password, ) if err != nil { return err } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: tok.Data.UserID, Action: "Account_ResetPassword", }) }) write(ctx, w, err, nil) }