package api_impl import ( "database/sql" "log/slog" "net/http" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/lib/access" ) type IdentityImpl struct { DB *sql.DB Users dbi.Factory[iam.UserStore] Passwords dbi.Factory[iam.PasswordStore] Audit dbi.Factory[iam.AuditStore] SessionMan *iam.SessionMan Throttle *AccountThrottle } func (s *IdentityImpl) ServeMux(mux *http.ServeMux) { mux.HandleFunc(api.Path_Identity_Login, s.Login) mux.HandleFunc(api.Path_Identity_Logout, s.Logout) } func (s *IdentityImpl) Login(w http.ResponseWriter, r *http.Request) { // Read and validate the request body var req api.Identity_LoginReq if read(w, r, &req) { return } ctx := r.Context() if !s.Throttle.Check(ctx, req.Email) { write(ctx, w, api.ErrThrottle, nil) return } ctx = access.PutSystem(ctx, iam.CapUsersGetByEmail, iam.CapPasswordsGet) // On failure, subject and failDetail feed the Identity_LoginFailed // entry written below. Unknown emails leave subject empty and are not // recorded — there is no account to attach the entry to. var subject core.ID var failDetail string token, err := dbi.Write(ctx, s.DB, func(tx dbi.DBI) (string, error) { users := s.Users(tx) passwords := s.Passwords(tx) var user iam.User if err := users.GetByEmail(ctx, req.Email, &user); err != nil { return "", api.ErrInvalidCredentials } subject = user.ID if err := iam.CheckPassword(ctx, passwords, user.ID, req.Password); err != nil { failDetail = "invalid password" return "", api.ErrInvalidCredentials } if !user.Verified { failDetail = "email not verified" return "", api.ErrEmailNotVerified } // No authenticated principal exists yet, so set the actor directly. err := audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: user.ID, Actor: user.ID.String(), Action: "Identity_Login", }) if err != nil { return "", err } return s.SessionMan.CreateSession(ctx, tx, user.ID) }) if err != nil { // A failed attempt on an existing account is still recorded. The // login transaction rolled back, so append in a fresh one. if !subject.IsEmpty() { auditErr := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { return audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: subject, Action: "Identity_LoginFailed", Detail: failDetail, }) }) if auditErr != nil { slog.ErrorContext(ctx, "recording failed login", "err", auditErr) } } write(ctx, w, err, nil) return } s.SessionMan.WriteCookie(w, token) write(ctx, w, nil, &api.Identity_LoginRes{}) } func (s *IdentityImpl) Logout(w http.ResponseWriter, r *http.Request) { ctx := r.Context() cookie, _ := r.Cookie(s.SessionMan.CookieName) if cookie != nil { err := dbi.ReadWrite(ctx, s.DB, func(tx dbi.DBI) error { if err := s.SessionMan.DeleteSession(ctx, tx, cookie.Value); err != nil { return err } // A stale cookie can reach here without a valid principal; // there's no subject to record then. userID, err := core.ParseID(iam.GetPrincipal(ctx).Subject) if err != nil || userID.IsEmpty() { return nil } return audit(ctx, s.Audit(tx), iam.AuditEntry{ Subject: userID, Action: "Identity_Logout", }) }) if err != nil { slog.ErrorContext(ctx, "deleting session", "err", err) } } s.SessionMan.ClearCookie(w) write(ctx, w, nil, nil) }