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:
22
classes/events.go
Normal file
22
classes/events.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package classes
|
||||
|
||||
import "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
|
||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||
type GetApiArrayEvent struct {
|
||||
User *models.User
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
// Collected returns the map listeners merge into the user payload.
|
||||
func (e *GetApiArrayEvent) Collected() map[string]any {
|
||||
if e.data == nil {
|
||||
e.data = map[string]any{}
|
||||
}
|
||||
return e.data
|
||||
}
|
||||
|
||||
// RegisterEvent is fired after a user row is created. No listener consumes it yet.
|
||||
type RegisterEvent struct {
|
||||
User *models.User
|
||||
}
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
"git.golem15.com/golem15/summercms/towel"
|
||||
)
|
||||
|
||||
// mailTemplate appends -en for English and leaves every other locale,
|
||||
// MailTemplate appends -en for English and leaves every other locale,
|
||||
// including empty and pl, on the unsuffixed Polish template name.
|
||||
func mailTemplate(base, locale string) string {
|
||||
func MailTemplate(base, locale string) string {
|
||||
if locale == "en" {
|
||||
return base + "-en"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// resolveMailLocale prefers the user's saved locale, then the request locale.
|
||||
func resolveMailLocale(ctx context.Context, user *models.User) string {
|
||||
// ResolveMailLocale prefers the user's saved locale, then the request locale.
|
||||
func ResolveMailLocale(ctx context.Context, user *models.User) string {
|
||||
if user != nil && user.PreferredLocale != nil && *user.PreferredLocale != "" {
|
||||
return *user.PreferredLocale
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import "testing"
|
||||
|
||||
func TestMailTemplate(t *testing.T) {
|
||||
base := "golem15.user::mail.reactivate"
|
||||
if got := mailTemplate(base, "pl"); got != base {
|
||||
if got := MailTemplate(base, "pl"); got != base {
|
||||
t.Fatalf("pl = %s", got)
|
||||
}
|
||||
if got := mailTemplate(base, ""); got != base {
|
||||
if got := MailTemplate(base, ""); got != base {
|
||||
t.Fatalf("empty = %s", got)
|
||||
}
|
||||
if got := mailTemplate(base, "en"); got != base+"-en" {
|
||||
if got := MailTemplate(base, "en"); got != base+"-en" {
|
||||
t.Fatalf("en = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,23 @@ func withThrottle(ctx context.Context, s throttleSettings) context.Context {
|
||||
return context.WithValue(ctx, throttleSettingsKey{}, s)
|
||||
}
|
||||
|
||||
// ContextWithThrottle copies golem15.user.throttle.* onto ctx for CheckAndRecordLogin.
|
||||
func ContextWithThrottle(ctx context.Context, app *backpack.App) context.Context {
|
||||
s := throttleSettings{use: true, limit: 5, minutes: 15}
|
||||
if app != nil && app.Config != nil {
|
||||
if app.Config.Has("golem15.user.throttle.use_throttle") {
|
||||
s.use = app.Config.Bool("golem15.user.throttle.use_throttle")
|
||||
}
|
||||
if n := app.Config.Int("golem15.user.throttle.attempt_limit"); n > 0 {
|
||||
s.limit = n
|
||||
}
|
||||
if n := app.Config.Int("golem15.user.throttle.suspension_minutes"); n > 0 {
|
||||
s.minutes = n
|
||||
}
|
||||
}
|
||||
return withThrottle(ctx, s)
|
||||
}
|
||||
|
||||
func throttleFrom(ctx context.Context) throttleSettings {
|
||||
if ctx != nil {
|
||||
if s, ok := ctx.Value(throttleSettingsKey{}).(throttleSettings); ok {
|
||||
@@ -38,10 +56,54 @@ func throttleFrom(ctx context.Context) throttleSettings {
|
||||
return throttleSettings{use: true, limit: 5, minutes: 15}
|
||||
}
|
||||
|
||||
// IsLoginRejected reports a ban or an active suspension. Callers map it to the
|
||||
// generic invalid-login response so the cause is not distinguishable.
|
||||
func IsLoginRejected(err error) bool {
|
||||
return errors.Is(err, errBanned) || errors.Is(err, errSuspended)
|
||||
}
|
||||
|
||||
// RejectIfThrottled enforces a ban or an active suspension without recording
|
||||
// an attempt. Login calls it before the password check; a missing row is allowed.
|
||||
func RejectIfThrottled(ctx context.Context, db *gorm.DB, user *models.User, ip string) error {
|
||||
if user == nil || user.ID == 0 || db == nil {
|
||||
return nil
|
||||
}
|
||||
settings := throttleFrom(ctx)
|
||||
if !settings.use {
|
||||
return nil
|
||||
}
|
||||
if settings.minutes <= 0 {
|
||||
settings.minutes = 15
|
||||
}
|
||||
var row models.Throttle
|
||||
err := db.WithContext(ctx).
|
||||
Where("user_id = ? AND (ip_address = ? OR ip_address IS NULL)", user.ID, ip).
|
||||
Order("ip_address NULLS LAST").
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return throttleBlocked(row, settings)
|
||||
}
|
||||
|
||||
func throttleBlocked(row models.Throttle, settings throttleSettings) error {
|
||||
if row.IsBanned {
|
||||
return errBanned
|
||||
}
|
||||
window := time.Duration(settings.minutes) * time.Minute
|
||||
if row.IsSuspended && row.SuspendedAt != nil && time.Now().Before(row.SuspendedAt.Add(window)) {
|
||||
return errSuspended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndRecordLogin applies Winter's failed-login throttle.
|
||||
// Call it once before the password check (ok=false) to enforce a ban or
|
||||
// suspension, and again after the outcome is known. A nil user is ignored
|
||||
// so an unknown email never creates a row.
|
||||
// Login calls RejectIfThrottled before the password check, then this function
|
||||
// once with the outcome. A nil user is ignored so an unknown email never
|
||||
// creates a row.
|
||||
func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip string, ok bool) error {
|
||||
if user == nil || user.ID == 0 || db == nil {
|
||||
return nil
|
||||
@@ -69,12 +131,8 @@ func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if row.IsBanned {
|
||||
return errBanned
|
||||
}
|
||||
window := time.Duration(settings.minutes) * time.Minute
|
||||
if row.IsSuspended && row.SuspendedAt != nil && time.Now().Before(row.SuspendedAt.Add(window)) {
|
||||
return errSuspended
|
||||
if err := throttleBlocked(row, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return db.WithContext(ctx).Model(&row).Updates(map[string]any{
|
||||
|
||||
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)
|
||||
}
|
||||
96
plugin.go
96
plugin.go
@@ -1,8 +1,14 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes"
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
@@ -11,6 +17,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -21,15 +28,18 @@ var (
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasMiddleware = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
_ bouncer.UserProvider = classes.GormUsers{}
|
||||
)
|
||||
|
||||
//go:embed config
|
||||
var configFS embed.FS
|
||||
|
||||
// Plugin is the stub golem15.user plugin.
|
||||
// Plugin is the golem15.user plugin.
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
app *backpack.App
|
||||
sweepStop chan struct{}
|
||||
}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.user" }
|
||||
@@ -58,7 +68,87 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, nil))
|
||||
var bl bouncer.BlacklistStore
|
||||
if sqlDB, ok := app.Lookup[*sql.DB](); ok && sqlDB != nil {
|
||||
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
|
||||
} else if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil {
|
||||
if sqlDB, err := gdb.DB(); err == nil && sqlDB != nil {
|
||||
bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")
|
||||
}
|
||||
}
|
||||
if bl != nil {
|
||||
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
|
||||
return err
|
||||
}
|
||||
p.startBlacklistSweep(bl)
|
||||
}
|
||||
return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token"))
|
||||
}
|
||||
|
||||
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
||||
interval := 10 * time.Minute
|
||||
if p.app != nil && p.app.Config != nil {
|
||||
raw := p.app.Config.String("golem15.user.jwt.blacklist_sweep_interval")
|
||||
if raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return
|
||||
}
|
||||
interval = parsed
|
||||
}
|
||||
}
|
||||
if p.sweepStop != nil {
|
||||
return
|
||||
}
|
||||
p.sweepStop = make(chan struct{})
|
||||
stop := p.sweepStop
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := bl.Sweep(context.Background(), time.Now()); err != nil {
|
||||
slog.Default().Error("golem15.user: jwt blacklist sweep", "err", err)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(nil)
|
||||
if p.app != nil {
|
||||
trusted = surf.TrustedProxies(p.app.Config)
|
||||
}
|
||||
secret := ""
|
||||
if p.app != nil {
|
||||
secret, _ = classes.JWTSecret(p.app)
|
||||
}
|
||||
return map[string]surf.Bucket{
|
||||
"user-api": {
|
||||
Max: 120,
|
||||
Decay: time.Minute,
|
||||
Key: func(r *http.Request) string {
|
||||
if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil {
|
||||
return "u:" + sub
|
||||
}
|
||||
return surf.ClientIP(r, trusted)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func bearerFrom(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 (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||
|
||||
19
routes.go
Normal file
19
routes.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
)
|
||||
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
|
||||
g.Post("/login", controllers.Login(p.app))
|
||||
g.Post("/logout", controllers.Logout(p.app))
|
||||
g.Get("/fetch", controllers.Fetch(p.app))
|
||||
g.Post("/refresh", controllers.Refresh(p.app))
|
||||
g.Post("/register", controllers.Register(p.app))
|
||||
g.Get("/oauth-providers", controllers.OAuthProviders(p.app))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
565
session_test.go
Normal file
565
session_test.go
Normal file
@@ -0,0 +1,565 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers"
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/updates"
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
"git.golem15.com/golem15/summercms/lagoon"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"git.golem15.com/golem15/summercms/postcard"
|
||||
"git.golem15.com/golem15/summercms/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionSecret = "summercms-test-only-hs256-secret"
|
||||
invalidLoginBody = `{"error":true,"message":"Invalid email or password"}`
|
||||
prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionOnce sync.Once
|
||||
sessionGDB *gorm.DB
|
||||
sessionSQL *sql.DB
|
||||
sessionErr error
|
||||
)
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
hash, err := bouncer.HashPassword(10, "correct-horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &models.User{Email: "ada-login@example.test", Password: hash}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := body["token"].(string)
|
||||
claims := parseToken(t, token)
|
||||
if claims["sub"] != strconv.FormatUint(uint64(user.ID), 10) {
|
||||
t.Fatalf("sub = %v", claims["sub"])
|
||||
}
|
||||
if claims["prv"] != prvHash {
|
||||
t.Fatalf("prv = %v", claims["prv"])
|
||||
}
|
||||
if claims["iss"] != "http://example.com/_user/api/v1/login" {
|
||||
t.Fatalf("iss = %v", claims["iss"])
|
||||
}
|
||||
userBody, _ := body["user"].(map[string]any)
|
||||
if userBody["email"] != user.Email || userBody["feedback_widget_hidden"] != false {
|
||||
t.Fatalf("user payload = %#v", userBody)
|
||||
}
|
||||
if _, ok := userBody["permissions"].([]any); !ok {
|
||||
t.Fatalf("permissions = %#v", userBody["permissions"])
|
||||
}
|
||||
|
||||
wrong := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"nope"}`)
|
||||
unknown := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"nobody@example.test","password":"nope"}`)
|
||||
if wrong.Code != http.StatusUnauthorized || wrong.Body.String() != invalidLoginBody {
|
||||
t.Fatalf("wrong = %d %s", wrong.Code, wrong.Body.String())
|
||||
}
|
||||
if unknown.Body.String() != wrong.Body.String() {
|
||||
t.Fatalf("unknown body %s", unknown.Body.String())
|
||||
}
|
||||
|
||||
var before int64
|
||||
if err := gdb.Model(&models.Throttle{}).Count(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ghost@example.test","password":"nope"}`)
|
||||
if again.Body.String() != invalidLoginBody {
|
||||
t.Fatal(again.Body.String())
|
||||
}
|
||||
var after int64
|
||||
if err := gdb.Model(&models.Throttle{}).Count(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != before {
|
||||
t.Fatalf("unknown email created a throttle row (%d -> %d)", before, after)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
ip := "192.0.2.1"
|
||||
throttled := &models.User{Email: "throttled@example.test", Password: hash}
|
||||
if err := gdb.Create(throttled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := models.Throttle{UserID: throttled.ID, IPAddress: &ip, Attempts: 5, IsSuspended: true, SuspendedAt: &now}
|
||||
if err := gdb.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"throttled@example.test","password":"correct-horse"}`)
|
||||
if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody {
|
||||
t.Fatalf("throttled = %d %s", blocked.Code, blocked.Body.String())
|
||||
}
|
||||
|
||||
loc := "en"
|
||||
soft := &models.User{Email: "soft@example.test", Password: hash, PreferredLocale: &loc}
|
||||
if err := gdb.Create(soft).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Model(soft).Update("deleted_at", time.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mail := &captureMail{}
|
||||
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"soft@example.test","password":"correct-horse"}`)
|
||||
if restored.Code != http.StatusOK {
|
||||
t.Fatalf("restore %d %s", restored.Code, restored.Body.String())
|
||||
}
|
||||
var againUser models.User
|
||||
if err := gdb.Unscoped().First(&againUser, soft.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if againUser.DeletedAt.Valid {
|
||||
t.Fatal("soft-deleted user was not restored")
|
||||
}
|
||||
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||
t.Fatalf("reactivate template = %#v", mail.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSixthAttempt(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
hash, err := bouncer.HashPassword(10, "correct-horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &models.User{Email: "sixth@example.test", Password: hash}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"wrong"}`)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||
t.Fatalf("attempt %d: %d %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
var row models.Throttle
|
||||
if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Attempts != 5 || !row.IsSuspended {
|
||||
t.Fatalf("after 5 logins: %+v", row)
|
||||
}
|
||||
blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"correct-horse"}`)
|
||||
if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody {
|
||||
t.Fatalf("6th = %d %s", blocked.Code, blocked.Body.String())
|
||||
}
|
||||
if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Attempts != 5 {
|
||||
t.Fatalf("6th login recorded another attempt: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
token := loginToken(t, app, gdb, "logout@example.test")
|
||||
rec := bearer(t, controllers.Logout(app), http.MethodPost, "/_user/api/v1/logout", token)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Logged out"}` {
|
||||
t.Fatalf("logout %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token)
|
||||
if fetch.Code != http.StatusUnauthorized || fetch.Body.String() != `{"error":true,"message":"Unauthorized"}` {
|
||||
t.Fatalf("fetch after logout %d %s", fetch.Code, fetch.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
token := loginToken(t, app, gdb, "fetch@example.test")
|
||||
ok := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token)
|
||||
if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), `"email":"fetch@example.test"`) {
|
||||
t.Fatalf("fetch %d %s", ok.Code, ok.Body.String())
|
||||
}
|
||||
missing := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "")
|
||||
if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":true,"message":"Unauthorized"}` {
|
||||
t.Fatalf("missing %d %s", missing.Code, missing.Body.String())
|
||||
}
|
||||
bad := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "not-a-token")
|
||||
if bad.Body.String() != missing.Body.String() {
|
||||
t.Fatalf("invalid %s", bad.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
user := insertUser(t, gdb, "refresh@example.test", "correct-horse")
|
||||
sub := strconv.FormatUint(uint64(user.ID), 10)
|
||||
|
||||
missing := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", "")
|
||||
if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":"Token not found"}` {
|
||||
t.Fatalf("missing %d %s", missing.Code, missing.Body.String())
|
||||
}
|
||||
|
||||
iat := time.Now().Add(-2 * time.Hour)
|
||||
old := craftToken(t, sub, "refresh-old", iat, iat.Add(time.Minute))
|
||||
rec := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", old)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("refresh %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, _ := body["token"].(string)
|
||||
if next == "" || next == old {
|
||||
t.Fatalf("new token = %q", next)
|
||||
}
|
||||
if parseToken(t, next)["jti"] == "refresh-old" {
|
||||
t.Fatal("refresh reused jti")
|
||||
}
|
||||
bl, ok := app.Lookup[bouncer.BlacklistStore]()
|
||||
if !ok || bl == nil {
|
||||
t.Fatal("blacklist was not published")
|
||||
}
|
||||
blocked, err := bl.IsBlacklisted(t.Context(), "refresh-old")
|
||||
if err != nil || !blocked {
|
||||
t.Fatalf("old jti blacklisted=%v err=%v", blocked, err)
|
||||
}
|
||||
fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", old)
|
||||
if fetch.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old token still fetches: %d %s", fetch.Code, fetch.Body.String())
|
||||
}
|
||||
|
||||
stale := craftToken(t, sub, "refresh-stale", time.Now().Add(-40*24*time.Hour), time.Now().Add(-39*24*time.Hour))
|
||||
denied := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", stale)
|
||||
if denied.Code != http.StatusUnauthorized || !strings.Contains(denied.Body.String(), `"error":"Could not refresh token"`) || !strings.Contains(denied.Body.String(), `"msg":`) {
|
||||
t.Fatalf("stale %d %s", denied.Code, denied.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRouteGroup(t *testing.T) {
|
||||
cfg := sessionConfig(t)
|
||||
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
p := &Plugin{}
|
||||
if err := p.Boot(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt, err := surf.BuildRouter(app, []party.Plugin{p})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{
|
||||
"POST /_user/api/v1/login": false,
|
||||
"POST /_user/api/v1/logout": false,
|
||||
"GET /_user/api/v1/fetch": false,
|
||||
"POST /_user/api/v1/refresh": false,
|
||||
"POST /_user/api/v1/register": false,
|
||||
"GET /_user/api/v1/oauth-providers": false,
|
||||
}
|
||||
for _, info := range rt.Routes() {
|
||||
key := info.Method + " " + info.Pattern
|
||||
if _, ok := want[key]; !ok {
|
||||
continue
|
||||
}
|
||||
want[key] = true
|
||||
if len(info.Middleware) != 1 || info.Middleware[0] != "throttle:user-api" {
|
||||
t.Fatalf("%s middleware = %#v", key, info.Middleware)
|
||||
}
|
||||
}
|
||||
for key, seen := range want {
|
||||
if !seen {
|
||||
t.Fatalf("missing route %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBlacklistSweep(t *testing.T) {
|
||||
gdb, sqlDB := sessionDB(t)
|
||||
app := newSessionApp(t, gdb, sqlDB)
|
||||
_, stop := bootPlugin(t, app, "50ms")
|
||||
past := time.Now().Add(-time.Minute)
|
||||
if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-me", past, past).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
var n int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-me").Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("expired jwt_blacklist row was not swept")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
stop()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
quiet := newSessionApp(t, gdb, sqlDB)
|
||||
bootPlugin(t, quiet, "0s")
|
||||
later := time.Now().Add(-time.Minute)
|
||||
if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-keep", later, later).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
var n int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-keep").Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatal("non-positive sweep interval removed a row")
|
||||
}
|
||||
}
|
||||
|
||||
type captureMail struct {
|
||||
got []string
|
||||
}
|
||||
|
||||
func (c *captureMail) Send(_ context.Context, msg postcard.Message) error {
|
||||
c.got = append(c.got, msg.Template)
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
|
||||
t.Helper()
|
||||
insertUser(t, gdb, email, "correct-horse")
|
||||
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"correct-horse"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %s: %d %s", email, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := body["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatal("empty token")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
|
||||
t.Helper()
|
||||
hash, err := bouncer.HashPassword(10, password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &models.User{Email: email, Password: hash}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Host = "example.com"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func bearer(t *testing.T, h http.HandlerFunc, method, path, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Host = "example.com"
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func parseToken(t *testing.T, raw string) jwt.MapClaims {
|
||||
t.Helper()
|
||||
claims := jwt.MapClaims{}
|
||||
if _, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) {
|
||||
return []byte(sessionSecret), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func craftToken(t *testing.T, sub, jti string, iat, exp time.Time) string {
|
||||
t.Helper()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iss": "http://example.com/_user/api/v1/login",
|
||||
"iat": iat.Unix(),
|
||||
"nbf": iat.Unix(),
|
||||
"exp": exp.Unix(),
|
||||
"jti": jti,
|
||||
"prv": prvHash,
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
func sessionApp(t *testing.T, sweep string) (*backpack.App, *gorm.DB) {
|
||||
t.Helper()
|
||||
gdb, sqlDB := sessionDB(t)
|
||||
app := backpack.New(sessionConfig(t))
|
||||
if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sweep != "" {
|
||||
if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bootPlugin(t, app, sweep)
|
||||
return app, gdb
|
||||
}
|
||||
|
||||
func newSessionApp(t *testing.T, gdb *gorm.DB, sqlDB *sql.DB) *backpack.App {
|
||||
t.Helper()
|
||||
app := backpack.New(sessionConfig(t))
|
||||
if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func bootPlugin(t *testing.T, app *backpack.App, sweep string) (*Plugin, func()) {
|
||||
t.Helper()
|
||||
if sweep != "" {
|
||||
if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p := &Plugin{}
|
||||
if err := p.Boot(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop := func() {}
|
||||
if p.sweepStop != nil {
|
||||
ch := p.sweepStop
|
||||
var once sync.Once
|
||||
stop = func() { once.Do(func() { close(ch) }) }
|
||||
t.Cleanup(stop)
|
||||
}
|
||||
return p, stop
|
||||
}
|
||||
|
||||
func sessionConfig(t *testing.T) *compass.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-session\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=development",
|
||||
"SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) {
|
||||
t.Helper()
|
||||
sessionOnce.Do(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
|
||||
postgres.WithDatabase("user_session"),
|
||||
postgres.WithUsername("user_session"),
|
||||
postgres.WithPassword("user_session"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
testcontainers.WithEnv(map[string]string{
|
||||
"POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8",
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
sessionErr = err
|
||||
return
|
||||
}
|
||||
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
sessionErr = err
|
||||
return
|
||||
}
|
||||
sqlDB, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
sessionErr = err
|
||||
return
|
||||
}
|
||||
gdb, err := lagoon.Use(ctx, sqlDB)
|
||||
if err != nil {
|
||||
sessionErr = err
|
||||
return
|
||||
}
|
||||
m := gormigrate.New(gdb, &gormigrate.Options{
|
||||
TableName: "summer_migrations_golem15_user",
|
||||
UseTransaction: true,
|
||||
}, updates.All())
|
||||
if err := m.Migrate(); err != nil {
|
||||
sessionErr = err
|
||||
return
|
||||
}
|
||||
sessionGDB = gdb
|
||||
sessionSQL = sqlDB
|
||||
})
|
||||
if sessionErr != nil {
|
||||
t.Fatal(sessionErr)
|
||||
}
|
||||
return sessionGDB, sessionSQL
|
||||
}
|
||||
Reference in New Issue
Block a user