diff --git a/bouncer/password.go b/bouncer/password.go new file mode 100644 index 0000000..799485a --- /dev/null +++ b/bouncer/password.go @@ -0,0 +1,27 @@ +package bouncer + +import "golang.org/x/crypto/bcrypt" + +// HashPassword returns a bcrypt hash of plain at the given cost. +func HashPassword(cost int, plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), cost) + if err != nil { + return "", err + } + return string(b), nil +} + +// CheckPassword reports whether plain matches hash. A malformed hash returns false. +func CheckPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} + +// NeedsRehash reports whether hash was produced below configuredCost. +// A hash bcrypt cannot parse needs a rehash. +func NeedsRehash(hash string, configuredCost int) bool { + cost, err := bcrypt.Cost([]byte(hash)) + if err != nil { + return true + } + return cost < configuredCost +} diff --git a/go.mod b/go.mod index 820cf7f..96da973 100644 --- a/go.mod +++ b/go.mod @@ -22,8 +22,9 @@ require ( github.com/wneessen/go-mail v0.8.1 github.com/yuin/goldmark v1.8.6 gocloud.dev v0.46.0 + golang.org/x/crypto v0.57.0 golang.org/x/term v0.46.0 - golang.org/x/text v0.41.0 + golang.org/x/text v0.42.0 gorm.io/driver/postgres v1.6.3 gorm.io/gorm v1.31.2 ) @@ -96,10 +97,9 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.272.0 // indirect diff --git a/go.sum b/go.sum index 8929066..b3e9f92 100644 --- a/go.sum +++ b/go.sum @@ -287,16 +287,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -306,8 +306,8 @@ golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= diff --git a/lagoon/validate.go b/lagoon/validate.go index f0f8f55..599859b 100644 --- a/lagoon/validate.go +++ b/lagoon/validate.go @@ -26,8 +26,7 @@ var validateOnce = validator.New(validator.WithRequiredStructEnabled()) func Validate(ctx context.Context, tx *gorm.DB, model any, rules map[string]string, values map[string]any, tr *phrasebook.Translator) (map[string][]string, error) { out := map[string][]string{} for field, rule := range rules { - val, _ := values[field] - msgs, err := validateField(ctx, tx, model, field, rule, val, tr) + msgs, err := validateField(ctx, tx, model, field, rule, values, tr) if err != nil { return nil, err } @@ -41,7 +40,8 @@ func Validate(ctx context.Context, tx *gorm.DB, model any, rules map[string]stri return out, nil } -func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule string, val any, tr *phrasebook.Translator) ([]string, error) { +func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule string, values map[string]any, tr *phrasebook.Translator) ([]string, error) { + val := values[field] tokens := splitRule(rule) nullable := false required := false @@ -82,6 +82,29 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri uniqueTable = arg case "boolean": // Go's bool field type already enforces this; treat as a type-check no-op. + case "email": + tags = append(tags, "email") + case "confirmed": + if fmt.Sprint(val) != fmt.Sprint(values[field+"_confirmation"]) { + return []string{validateMessage(ctx, tr, "confirmed", field, nil)}, nil + } + case "different": + if fmt.Sprint(val) == fmt.Sprint(values[arg]) { + return []string{validateMessage(ctx, tr, "different", field, map[string]string{"other": arg})}, nil + } + case "mimes": + got := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(fmt.Sprint(val))), ".") + match := false + for _, ext := range strings.Split(arg, ",") { + want := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + if got != "" && got != "" && got == want { + match = true + break + } + } + if !match { + return []string{validateMessage(ctx, tr, "mimes", field, nil)}, nil + } default: return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok) } @@ -382,6 +405,14 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field return "The " + field + " must be at least " + params["min"] + "." case "oneof": return "The selected " + field + " is invalid." + case "email": + return "The " + field + " must be a valid email address." + case "confirmed": + return "The " + field + " confirmation does not match." + case "different": + return "The " + field + " and " + params["other"] + " must be different." + case "mimes": + return "The " + field + " must be a file of the allowed types." default: return "The " + field + " is invalid." } diff --git a/surf/locale_from_principal.go b/surf/locale_from_principal.go new file mode 100644 index 0000000..24de6be --- /dev/null +++ b/surf/locale_from_principal.go @@ -0,0 +1,19 @@ +package surf + +import ( + "net/http" + + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/towel" +) + +// LocaleFromPrincipal overrides the header locale with the authenticated +// principal's PreferredLocale when that value is non-empty. +func LocaleFromPrincipal(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if p, ok := bouncer.User(r.Context()); ok && p.PreferredLocale != "" { + r = r.WithContext(towel.WithLocale(r.Context(), p.PreferredLocale)) + } + next.ServeHTTP(w, r) + }) +} diff --git a/surf/router.go b/surf/router.go index afa749c..791bb9f 100644 --- a/surf/router.go +++ b/surf/router.go @@ -453,6 +453,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { }); err != nil { return nil, err } + if err := r.RegisterMiddleware("surf", "locale.from-principal", LocaleFromPrincipal); err != nil { + return nil, err + } if app != nil { corsCfg, err := LoadCORSConfig(app.Config) if err != nil {