Golar v0.5.1

Authentication and authorization

The auth package and http/session cover login, sessions, API tokens, authorization, password resets, email verification, rate limiting and CSRF. All middleware has the standard func(http.Handler) http.Handler shape, so it works with app.Use, http.ServeMux and any router.

Users

User models implement auth.Authenticatable:

type User struct {
    orm.Model
    Email           string
    Password        string     // hash, never plaintext
    EmailVerifiedAt *time.Time
}

func (u *User) AuthID() string       { return strconv.FormatInt(u.ID, 10) }
func (u *User) AuthPassword() string { return u.Password }

auth.ORMProvider[User]{DB: db} loads users by ID and by the email column (configurable with IdentifierColumn, PasswordColumn, VerifiedColumn). Any type implementing auth.UserProvider can replace it.

auth.User(ctx) returns the authenticated user, and auth.UserAs[*User](ctx) returns it typed.

Passwords

hash, err := auth.HashPassword("correct horse")
ok := auth.VerifyPassword(hash, "correct horse")

New hashes use Argon2id in PHC format with OWASP's minimum parameters (19 MiB, 2 iterations, 1 thread). Tune them with auth.Passwords{Argon2id: auth.Argon2id{…}} and assign it to auth.DefaultHasher. bcrypt hashes ($2a$, $2b$, $2y$, e.g. from Laravel) still verify and are upgraded to Argon2id on the next login. Malformed hashes, or hashes with parameters outside sane bounds, never verify. Passwords over 4096 bytes are rejected; bcrypt rejects passwords over 72 bytes rather than truncating them.

Login

login := &auth.Authenticator{
    Provider: provider,
    Limiter:  auth.NewLimiter(5, time.Minute), // per identifier
}
user, err := login.Attempt(ctx, email, password)
switch {
case errors.Is(err, auth.ErrTooManyAttempts): // 429; errors.As gives *auth.RateLimitError.RetryAfter
case errors.Is(err, auth.ErrInvalidCredentials): // 401
}

Unknown emails and wrong passwords return the same error after the same amount of hashing work, so responses do not reveal which accounts exist.

Sessions

sessions := session.NewManager() // in-memory store, secure cookie defaults; see Session stores
guard := &auth.SessionGuard{Provider: provider}

app.Use(sessions.Middleware, auth.CSRF)

// in the login handler:
err = guard.Login(r, user)  // regenerates the session ID, rotates the CSRF token
// in the logout handler:
err = guard.Logout(r)

Session cookies are HttpOnly, Secure and SameSite=Lax by default. Set Insecure: true only for local HTTP. Unknown session IDs from clients are never adopted, which prevents session fixation. Changing a user's password ends their existing sessions. Change the session before writing the response body.

Session stores

session.NewManager() uses session.MemoryStore, which loses sessions on restart and is not shared between instances. Use session.SQLStore in production and behind load balancers:

// in a migration:
err := session.CreateTables(schema) // a "sessions" table: id, payload, expires_at

store := &session.SQLStore{DB: db} // Table defaults to "sessions"
sessions := &session.Manager{Store: store}

app.OnStart(func(ctx context.Context) error {
    go store.PruneEvery(ctx, time.Hour) // delete expired rows until shutdown
    return nil
})

Rows are keyed by the SHA-256 of the session ID, like API tokens, so a leaked table does not contain usable cookies. Data is stored as JSON. Expired sessions never load; store.Prune(ctx) deletes them and returns how many were removed. SQLite, PostgreSQL and MySQL are supported; MySQL DSNs need parseTime=true. Implement session.Store for other backends such as Redis.

Generated projects with session authentication create the table in their first migration and use the SQL store unless SESSION_DRIVER=memory.

API tokens

tokens := &auth.TokenGuard{Provider: provider, Store: &auth.SQLTokenStore{DB: db}}
plaintext, token, err := tokens.Issue(ctx, user, "cli", []string{"posts:*"}, 30*24*time.Hour)
// show plaintext once; only its SHA-256 hash is stored
err = tokens.Revoke(ctx, plaintext)

Clients send Authorization: Bearer golar_…. Abilities support * and resource:* wildcards. auth.TokenFrom(ctx) returns the token of the current request.

Create the token and password-reset tables in a migration with auth.CreateTables(schema).

Protecting routes

auth.Required(guard, tokens)    // 401 unless a guard authenticates; tries guards in order
auth.Optional(guard, tokens)    // sets the user when present
auth.RequireAbility("posts:write") // token abilities; after Required
auth.RequireVerified            // 403 for unverified emails; after Required

Gates and policies

gate := auth.NewGate()
gate.Define("view-dashboard", func(ctx context.Context, u auth.Authenticatable, _ ...any) bool {
    return u.(*User).IsAdmin
})
auth.Policy(gate, "update", func(ctx context.Context, u auth.Authenticatable, p *Post) bool {
    return u.AuthID() == strconv.FormatInt(p.UserID, 10)
})
gate.Before(func(ctx context.Context, u auth.Authenticatable, ability string) (allow, decided bool) {
    return true, u.(*User).IsSuperAdmin
})

if err := gate.Authorize(ctx, auth.User(ctx), "update", post); err != nil { … } // ErrForbidden
mux.Handle("/dashboard", auth.Required(guard)(gate.Can("view-dashboard")(handler)))

Checks run in order: Before hooks, the policy for the first argument's type, the defined ability, then RBAC permissions. Anything else, including every check for an anonymous user, is denied.

Roles and permissions

rbac := auth.NewRBAC()
rbac.Grant("editor", "posts.create", "posts.update")
rbac.Grant("admin", "*")
rbac.Inherit("admin", "editor")
gate.UseRBAC(rbac)

func (u *User) AuthRoles() []string { return strings.Split(u.Roles, ",") } // auth.HasRoles

rbac.RequireRole("admin")
rbac.RequirePermission("posts.update")

Role definitions live in memory; users' role assignments come from your model through HasRoles, for example a column or an ORM relation.

Password reset

broker := &auth.PasswordBroker{
    Provider: provider,                       // must implement auth.PasswordUpdater
    Store:    &auth.SQLResetStore{DB: db},
    // or func(ctx, user, token) error for your own delivery
    Send: auth.PasswordResetMail{Mailer: mailer, URL: "https://app.example/reset-password"}.Send,
    OnReset: func(ctx context.Context, u auth.Authenticatable) error {
        return tokenStore.DeleteUser(ctx, u.AuthID()) // log out API clients
    },
}
err := broker.SendResetLink(ctx, email)              // nil for unknown emails
err = broker.Reset(ctx, email, token, newPassword)

Tokens are random 256-bit values stored as SHA-256 hashes, expire after TTL (1 hour), are single use and are throttled to one link per Throttle (1 minute). Passwords shorter than MinLength (8) are rejected. Show the same message whether or not the email exists.

Email verification

verification := &auth.EmailVerification{
    Secret:   []byte(os.Getenv("APP_KEY")), // at least 32 random bytes
    Provider: provider,                     // must implement auth.EmailVerifiedMarker
    Send:     auth.VerificationMail{Mailer: mailer, URL: "https://app.example/api/auth/verify-email"}.Send,
}
err := verification.SendVerification(ctx, user)
user, err := verification.Verify(ctx, token)

Users implement auth.MustVerifyEmail (AuthEmail(), EmailVerified()). See the mail guide for drivers, templates and the link emails. Tokens are HMAC-SHA256 signed, expire after TTL (24 hours) and are bound to the current email address, so changing it invalidates outstanding links.

Rate limiting

limiter := auth.NewLimiter(10, time.Minute)
mux.Handle("POST /login", auth.RateLimit(limiter, nil)(loginHandler)) // per client IP

Over-limit requests get 429 Too Many Requests with Retry-After. The default key is the connection's IP; X-Forwarded-For is ignored because clients can forge it. Behind a trusted proxy, pass a key function that reads the proxy's header. Limits are kept per process.

CSRF

auth.CSRF requires the session's token on every request except GET, HEAD, OPTIONS and TRACE. Send it as the X-CSRF-Token header or the _token form field:

<input type="hidden" name="_token" value="{{ .CSRFToken }}">

where CSRFToken comes from auth.CSRFToken(r). Requests without a session token are rejected. APIs authenticated only by bearer tokens do not need CSRF protection; mount them without it.

Edit this page on GitHub