feat(07-02): serve login, logout, fetch, and refresh
Wire the user API session loop to the JWT blacklist and a pre-password throttle gate so a logged-out token cannot be fetched again. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
414
controllers/api_controller.go
Normal file
414
controllers/api_controller.go
Normal file
@@ -0,0 +1,414 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user