package controllers import ( "context" "encoding/json" "errors" "io" "net" "net/http" "strconv" "strings" "time" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/postcard" "git.golem15.com/golem15/summercms/wire" "gorm.io/gorm" ) const invalidLoginBody = "Invalid email or password" // Login authenticates email and password and returns a JWT plus the user payload. func Login(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } fields, err := readFields(r) if err != nil { writeOpaque500(w) return } email := strField(fields, "email") password := strField(fields, "password") ctx := classes.ContextWithThrottle(r.Context(), app) user, err := classes.LookupByEmail(ctx, gdb, email) if err != nil { writeOpaque500(w) return } ip := remoteIP(r) if user != nil { if err := classes.RejectIfThrottled(ctx, gdb, user, ip); err != nil { if classes.IsLoginRejected(err) { writeInvalidLogin(w) return } writeOpaque500(w) return } } if user == nil || !bouncer.CheckPassword(user.Password, password) { if user != nil { if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) { writeOpaque500(w) return } } writeInvalidLogin(w) return } if user.DeletedAt.Valid { if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil { writeOpaque500(w) return } sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email) } if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil { if classes.IsLoginRejected(err) { writeInvalidLogin(w) return } writeOpaque500(w) return } cost := 10 if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 { cost = app.Config.Int("golem15.user.password.bcrypt_cost") } if bouncer.NeedsRehash(user.Password, cost) { if next, err := bouncer.HashPassword(cost, password); err == nil { _ = gdb.WithContext(ctx).Model(user).Update("password", next).Error } } token, err := mintFor(app, secret, user, requestURL(r)) if err != nil { writeOpaque500(w) return } payload, err := apiArray(ctx, app, user) if err != nil { writeOpaque500(w) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload}) } } // Logout forever-blacklists the presented bearer token. func Logout(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _, secret, ok := sessionDeps(w, app) if !ok { return } raw := bearerToken(r) sub, _, exp, jti, err := bouncer.VerifyClaims(raw, secret) if err != nil || jti == "" { writeUnauthorized(w) return } id, err := strconv.ParseUint(sub, 10, 64) if err != nil || id == 0 { writeUnauthorized(w) return } principal, err := (classes.GormUsers{App: app}).FindByID(r.Context(), uint(id)) if err != nil || principal == nil { writeUnauthorized(w) return } if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil { if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil { writeOpaque500(w) return } } wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Logged out"}) } } // Fetch returns the authenticated user's payload. func Fetch(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := authenticate(w, r, app) if !ok { return } payload, err := apiArray(r.Context(), app, user) if err != nil { writeOpaque500(w) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"user": payload}) } } // Refresh slides a still-refreshable bearer token. func Refresh(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _, secret, ok := sessionDeps(w, app) if !ok { return } raw := bearerToken(r) if raw == "" { wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"}) return } var bl bouncer.BlacklistStore if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { bl = store } next, err := bouncer.Refresh(secret, raw, refreshTTL(app), bl, blacklistGrace(app), requestURL(r)) if err != nil { wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{ "error": "Could not refresh token", "msg": err.Error(), }) return } wire.WriteJSON(w, http.StatusOK, map[string]any{"token": next}) } } // OAuthProviders reports that this app configures no social providers. func OAuthProviders(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, http.StatusOK, map[string]any{ "success": true, "providers": wire.Slice([]any{}), }) } } // Register is filled in by the registration task. The route is declared now. func Register(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { writeOpaque500(w) } } func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) { gdb, secret, ok := sessionDeps(w, app) if !ok { return nil, false } raw := bearerToken(r) sub, _, _, _, err := bouncer.VerifyClaims(raw, secret) if err != nil { writeUnauthorized(w) return nil, false } id, err := strconv.ParseUint(sub, 10, 64) if err != nil || id == 0 { writeUnauthorized(w) return nil, false } var bl bouncer.BlacklistStore if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { bl = store } if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil { writeUnauthorized(w) return nil, false } var user models.User if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil { writeUnauthorized(w) return nil, false } return &user, true } func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) { if app == nil { writeOpaque500(w) return nil, "", false } gdb, ok := app.Lookup[*gorm.DB]() if !ok || gdb == nil { writeOpaque500(w) return nil, "", false } secret, err := classes.JWTSecret(app) if err != nil { writeOpaque500(w) return nil, "", false } return gdb, secret, true } func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[string]any, error) { name, surname := "", "" if user.Name != nil { name = *user.Name } if user.Surname != nil { surname = *user.Surname } payload := map[string]any{ "id": user.ID, "name": name, "surname": surname, "email": user.Email, "is_activated": user.IsActivated, "permissions": []string{}, "avatar": nil, "avatar_url": nil, "has_avatar": false, "marketing_consent": user.MarketingConsent, "groups": map[string]string{}, "role": nil, "is_onboarded": user.IsOnboarded, "has_self_set_password": user.HasSelfSetPassword, // feedback_widget_hidden stays false until a feedback plugin listens. "feedback_widget_hidden": false, } if app != nil && app.Events != nil { extra, err := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user}) if err != nil { return nil, err } for k, v := range extra { payload[k] = v } } return payload, nil } func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) { ttl := 60 * time.Minute if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 { ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute } token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl) return token, err } func refreshTTL(app *backpack.App) time.Duration { mins := 20160 if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 { mins = app.Config.Int("golem15.user.jwt.refresh_ttl") } return time.Duration(mins) * time.Minute } func blacklistGrace(app *backpack.App) time.Duration { secs := 0 if app != nil && app.Config != nil && app.Config.Has("golem15.user.jwt.blacklist_grace") { secs = app.Config.Int("golem15.user.jwt.blacklist_grace") } return time.Duration(secs) * time.Second } func mailName(ctx context.Context, user *models.User, base string) string { return classes.MailTemplate(base, classes.ResolveMailLocale(ctx, user)) } func sendTemplate(ctx context.Context, app *backpack.App, template, to string) { if app == nil || to == "" { return } mailer, ok := app.Lookup[postcard.Mailer]() if !ok || mailer == nil { return } _ = mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}}) } func readFields(r *http.Request) (map[string]any, error) { ct := r.Header.Get("Content-Type") if strings.Contains(ct, "application/json") { var fields map[string]any dec := json.NewDecoder(r.Body) if err := dec.Decode(&fields); err != nil && !errors.Is(err, io.EOF) { return nil, err } if fields == nil { fields = map[string]any{} } return fields, nil } if err := r.ParseForm(); err != nil { return nil, err } fields := map[string]any{} for k, v := range r.Form { if len(v) > 0 { fields[k] = v[0] } } return fields, nil } func strField(fields map[string]any, key string) string { v, ok := fields[key] if !ok || v == nil { return "" } s, ok := v.(string) if !ok { return strings.TrimSpace(jsonNumber(v)) } return strings.TrimSpace(s) } func jsonNumber(v any) string { switch n := v.(type) { case float64: return strconv.FormatFloat(n, 'f', -1, 64) default: return "" } } func bearerToken(r *http.Request) string { h := strings.TrimSpace(r.Header.Get("Authorization")) token, ok := strings.CutPrefix(h, "Bearer ") token = strings.TrimSpace(token) if !ok || token == "" { return "" } return token } func requestURL(r *http.Request) string { scheme := "http" if r.TLS != nil { scheme = "https" } if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" { scheme = proto } return scheme + "://" + r.Host + r.URL.Path } func remoteIP(r *http.Request) string { if r.RemoteAddr == "" { return "" } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func writeInvalidLogin(w http.ResponseWriter) { wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": invalidLoginBody}) } func writeUnauthorized(w http.ResponseWriter) { wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"}) } func writeOpaque500(w http.ResponseWriter) { wire.WriteOpaque500(w) }