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:
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user