feat: provider source for oauth-providers and sign-in helpers for social login

- classes.OAuthProvider and classes.OAuthProviderSource; GET /oauth-providers
  looks the source up per request and keeps the empty body without one
- exported RegistrationSettings, RegistrationsFromIP, RefreshTTL, RemoteIP
- RegisterOptions.PasswordLess (has_self_set_password=false) and FinishLogin
  (restore + reactivate mail, last_ip_address, last_seen; no event)
- TestOAuthProvidersSourceSmoke, TestSocialHooksSmoke; README
This commit is contained in:
Jakub Zych
2026-10-20 23:02:37 +02:00
parent 1fc0ce7ed0
commit 78a867bccb
6 changed files with 294 additions and 6 deletions

View File

@@ -212,17 +212,36 @@ func Refresh(app *backpack.App) http.HandlerFunc {
}
}
// OAuthProviders reports that this app configures no social providers.
// OAuthProviders lists the social sign-in providers a social-login plugin
// has enabled, as {success: true, providers: [{name, label}]}. The plugin
// publishes a classes.OAuthProviderSource, looked up on every request
// because it boots after this plugin; without one the list is empty.
func OAuthProviders(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
providers := []any{}
if src, ok := oauthProviderSource(app); ok {
for _, p := range src.EnabledOAuthProviders(r.Context()) {
providers = append(providers, map[string]any{"name": p.Name, "label": p.Label})
}
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"providers": wire.Slice([]any{}),
"providers": wire.Slice(providers),
})
}
}
const forgotPasswordBody = "If that email exists, a reset link has been sent."
// oauthProviderSource returns the social-login plugin's published provider
// source, if any.
func oauthProviderSource(app *backpack.App) (classes.OAuthProviderSource, bool) {
if app == nil {
return nil, false
}
src, ok := app.Lookup[classes.OAuthProviderSource]()
return src, ok && src != nil
}
const forgotPasswordBody ="If that email exists, a reset link has been sent."
// ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
func ForgotPassword(app *backpack.App) http.HandlerFunc {
@@ -682,6 +701,23 @@ func Register(app *backpack.App) http.HandlerFunc {
}
}
// RegistrationSettings returns the registration settings /register
// applies: allow is golem15.user.registration.allow_registration (default
// true), throttle is golem15.user.registration.use_register_throttle
// (default true), mode is golem15.user.activation.activate_mode (auto, user
// or admin; default auto) and require is
// golem15.user.activation.require_activation (default true).
func RegistrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
return registrationSettings(app)
}
// RegistrationsFromIP counts the users created from ip in the last 60
// minutes (created_ip_address). /register refuses a registration when the
// throttle is on and the count is 3 or more. A query error counts as 0.
func RegistrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64 {
return registrationsFromIP(ctx, gdb, ip)
}
func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
allow, throttle, require = true, true, true
mode = "auto"
@@ -831,6 +867,13 @@ func mintFor(app *backpack.App, secret string, user *models.User, issuer string)
return token, err
}
// RefreshTTL is the refresh window of a session token:
// golem15.user.jwt.refresh_ttl minutes, default 20160 (two weeks). A cookie
// carrying the token should live this long.
func RefreshTTL(app *backpack.App) time.Duration {
return refreshTTL(app)
}
func refreshTTL(app *backpack.App) time.Duration {
mins := 20160
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
@@ -1012,6 +1055,13 @@ func requestURL(r *http.Request) string {
return scheme + "://" + r.Host + r.URL.Path
}
// RemoteIP returns the host part of r.RemoteAddr, the address /login uses
// for its throttle (r.RemoteAddr itself when it has no port, "" when it is
// empty). /register records surf.ClientIP with the trusted proxies instead.
func RemoteIP(r *http.Request) string {
return remoteIP(r)
}
func remoteIP(r *http.Request) string {
if r.RemoteAddr == "" {
return ""

View File

@@ -3,6 +3,7 @@ package controllers
import (
"context"
"errors"
"log/slog"
"maps"
"net/http"
"time"
@@ -26,6 +27,11 @@ import (
type RegisterOptions struct {
Activate bool
IP string
// PasswordLess creates the account with has_self_set_password=false: its
// password is random and was never known to the user (social sign-in,
// PHP password_bootstrap_source oauth_registration). The zero value
// keeps true.
PasswordLess bool
}
// RegisterUser validates fields against the user model's rules, hashes the
@@ -68,7 +74,7 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil {
return nil, nil, err
}
user.HasSelfSetPassword = true
user.HasSelfSetPassword = !opts.PasswordLess
if opts.Activate {
user.IsActivated = true
now := time.Now()
@@ -83,6 +89,40 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
return user, nil, nil
}
// FinishLogin runs the steps password Login takes after it has
// authenticated a user, for a caller that authenticated the user another way
// (a social sign-in). It restores a soft-deleted account and sends the
// reactivate mail, records ip as last_ip_address, and touches last_seen. It
// checks no password, throttle, activation or ban and fires no event; the
// caller does those checks (Go has no login event). A failed last_seen
// write is logged and does not fail it, as in Login.
func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error {
if db == nil {
return errors.New("golem15.user: gorm handle is missing")
}
if user == nil || user.ID == 0 {
return errors.New("golem15.user: finishing a login needs a stored user")
}
if user.DeletedAt.Valid {
if err := db.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil {
return err
}
user.DeletedAt = gorm.DeletedAt{}
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
}
if ip != "" {
if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil {
return err
}
addr := ip
user.LastIPAddress = &addr
}
if err := classes.TouchLastSeen(ctx, db, user.ID); err != nil {
slog.Default().Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err)
}
return nil
}
// FireRegisterEvent fires classes.RegisterEvent for user with Payload set
// to a copy of fields without password and password_confirmation, and
// returns the listeners' error. /register ignores that error, as Winter