diff --git a/README.md b/README.md index a42ba1d..5d9a5cb 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ Packages: - `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule. - `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction. - `TouchLastSeen(ctx, db, userID)`. + - `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` blacklists the request's JWT until it expires (as `POST /logout` does) and writes the forget cookie of `bouncer.LoadCookieConfig`, best effort: a blacklist failure is logged, never returned. - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. - `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`. - `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). diff --git a/classes/session_user.go b/classes/session_user.go new file mode 100644 index 0000000..60ed74b --- /dev/null +++ b/classes/session_user.go @@ -0,0 +1,152 @@ +package classes + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "gorm.io/gorm" +) + +// sessionCookieNames are the cookies the plugin's "jwt" guard reads after +// the Authorization header, in the same order (plugin.go Boot). +var sessionCookieNames = []string{"token", "auth_token"} + +// RequestUser returns the signed-in user of r for handlers that run without +// the jwt.auth middleware and must also serve anonymous requests (an OAuth +// provider's authorize page, for example). The token is read and checked +// exactly as the plugin's "jwt" guard does: the Authorization bearer header +// first, then the token and auth_token cookies; the plugin's JWT secret; +// the published bouncer.BlacklistStore when there is one; and the user's +// tokens_valid_after cutoff. A soft-deleted user is not returned. +// +// An anonymous request, or a token that is invalid, expired, blacklisted or +// names no user, returns (nil, nil). Only an infrastructure failure (no +// database handle, a failing query or blacklist lookup) returns an error. +func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*models.User, error) { + if r == nil { + return nil, nil + } + raw := sessionToken(r) + if raw == "" { + return nil, nil + } + secret, err := JWTSecret(app) + if err != nil { + return nil, err + } + sub, iat, _, jti, err := bouncer.VerifyClaims(raw, secret) + if err != nil { + return nil, nil + } + id, err := strconv.ParseUint(sub, 10, 64) + if err != nil || id == 0 { + return nil, nil + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok || gdb == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + var user models.User + err = gdb.WithContext(ctx).Where("id = ?", uint(id)).Take(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil { + blocked, err := bl.IsBlacklisted(ctx, jti) + if err != nil { + return nil, err + } + if blocked { + return nil, nil + } + } + if user.TokensValidAfter != nil && iat.Before(*user.TokensValidAfter) { + return nil, nil + } + return &user, nil +} + +// EndRequestSession signs the request's user out the way Logout does and +// forgets the token cookie. When r carries a valid session JWT its jti is +// added to the published bouncer.BlacklistStore until the token expires; +// the response always gets the forget cookie of +// bouncer.LoadCookieConfig(app.Config), so its Domain and Path match the +// cookie the login wrote. It is best effort, as in the PHP provider: a +// blacklist failure is logged and never returned, and the function writes +// no status or body. +func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error { + if r != nil { + if raw := sessionToken(r); raw != "" { + revokeSessionToken(ctx, app, raw) + } + } + if w == nil { + return nil + } + cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode} + if app != nil && app.Config != nil { + loaded, err := bouncer.LoadCookieConfig(app.Config) + if err != nil { + slog.Default().Warn("golem15.user: end session: cookie config", "err", err) + } else { + cfg = loaded + } + } + http.SetCookie(w, cfg.ForgetTokenCookie()) + return nil +} + +// revokeSessionToken blacklists raw's jti until its expiry (best effort). +func revokeSessionToken(ctx context.Context, app *backpack.App, raw string) { + if app == nil { + return + } + secret, err := JWTSecret(app) + if err != nil { + return + } + _, _, exp, jti, err := bouncer.VerifyClaims(raw, secret) + if err != nil || jti == "" { + return + } + bl, ok := app.Lookup[bouncer.BlacklistStore]() + if !ok || bl == nil { + return + } + if err := bl.Add(ctx, jti, exp, time.Now()); err != nil { + slog.Default().Warn("golem15.user: end session: blacklist token", "err", err) + } +} + +// sessionToken extracts the session JWT as the "jwt" guard does: the +// Authorization bearer header, then each session cookie in order. +func sessionToken(r *http.Request) string { + h := strings.TrimSpace(r.Header.Get("Authorization")) + if token, ok := strings.CutPrefix(h, "Bearer "); ok { + if token = strings.TrimSpace(token); token != "" { + return token + } + } + for _, name := range sessionCookieNames { + c, err := r.Cookie(name) + if err != nil { + continue + } + if v := strings.TrimSpace(c.Value); v != "" { + return v + } + } + return "" +}