feat(07-01): add bcrypt, locale override, and validation rules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
27
bouncer/password.go
Normal file
27
bouncer/password.go
Normal file
@@ -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
|
||||
}
|
||||
8
go.mod
8
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
|
||||
|
||||
16
go.sum
16
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=
|
||||
|
||||
@@ -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 != "<nil>" && 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."
|
||||
}
|
||||
|
||||
19
surf/locale_from_principal.go
Normal file
19
surf/locale_from_principal.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user