0a0efd9115
Every argon2 run takes 64 MiB and nothing limited how many ran at once, so parallel sign-in attempts could run the server out of memory (8 at once used about 600 MB). At most two now run at once; at most 16 sign-ins wait for one, more get HTTP 429. 30 parallel sign-ins peaked at 275 MB. A sign-in attempt now counts toward the lockout before its password is checked, so parallel attempts cannot get past it; a right password takes its own attempt back. IPv6 addresses are locked out by /64.
378 lines
9.7 KiB
Go
378 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
// --- passwords (argon2id, PHC string format) ---
|
|
|
|
const (
|
|
argonTime = 3
|
|
argonMemory = 64 * 1024
|
|
argonThreads = 2
|
|
argonKeyLen = 32
|
|
)
|
|
|
|
// Every argon2 run takes argonMemory (64 MiB). argonSlots caps how many run
|
|
// at once, so a burst of sign-ins cannot run the server out of memory: two
|
|
// slots are 128 MiB at most.
|
|
var argonSlots = make(chan struct{}, 2)
|
|
|
|
func argonKey(pw, salt []byte, t, m uint32, p uint8, n uint32) []byte {
|
|
argonSlots <- struct{}{}
|
|
defer func() { <-argonSlots }()
|
|
return argon2.IDKey(pw, salt, t, m, p, n)
|
|
}
|
|
|
|
func hashPassword(pw string) (string, error) {
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", err
|
|
}
|
|
key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
|
b64 := base64.RawStdEncoding
|
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
|
}
|
|
|
|
func verifyPassword(encoded, pw string) bool {
|
|
parts := strings.Split(encoded, "$")
|
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
|
return false
|
|
}
|
|
var m uint32
|
|
var t uint32
|
|
var p uint8
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
|
return false
|
|
}
|
|
b64 := base64.RawStdEncoding
|
|
salt, err1 := b64.DecodeString(parts[4])
|
|
want, err2 := b64.DecodeString(parts[5])
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
got := argonKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
|
return subtle.ConstantTimeCompare(got, want) == 1
|
|
}
|
|
|
|
func validatePassword(pw string) error {
|
|
if len([]rune(pw)) < 12 {
|
|
return badRequest("password must be at least 12 characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- random secrets ---
|
|
|
|
func randomString(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func newID() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
b[6] = b[6]&0x0f | 0x40
|
|
b[8] = b[8]&0x3f | 0x80
|
|
h := hex.EncodeToString(b)
|
|
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
|
}
|
|
|
|
const tokenPrefix = "wgt_"
|
|
|
|
func hashToken(tok string) string {
|
|
sum := sha256.Sum256([]byte(tok))
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// --- sessions, token use and login throttling (in memory) ---
|
|
|
|
type principal struct {
|
|
Name string // the username, or the token name
|
|
UserID string // the user, or the token's owner
|
|
Scope string // rw | ro
|
|
TokenID string
|
|
IsAdmin bool // a signed-in user, not an API token
|
|
RemoteIP string
|
|
// MustChangePassword blocks everything but changing the password.
|
|
MustChangePassword bool
|
|
// MFASetupRequired blocks everything but setting up two-step sign-in,
|
|
// when it is required and the user has none.
|
|
MFASetupRequired bool
|
|
Session *sessionInfo // nil for API tokens
|
|
}
|
|
|
|
// sessionInfo is when and from where a browser session started.
|
|
type sessionInfo struct {
|
|
Started time.Time `json:"started"`
|
|
IP string `json:"ip"`
|
|
}
|
|
|
|
type session struct {
|
|
userID string
|
|
// stamp is the user's password hash at sign-in: a changed or reset
|
|
// password ends every session started with the old one.
|
|
stamp string
|
|
expires time.Time
|
|
info sessionInfo
|
|
}
|
|
|
|
type tokenUse struct {
|
|
At time.Time `json:"at"`
|
|
IP string `json:"ip"`
|
|
}
|
|
|
|
type failState struct {
|
|
count int
|
|
until time.Time
|
|
}
|
|
|
|
type Auth struct {
|
|
store *Store
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*session
|
|
used map[string]tokenUse
|
|
logins map[string]tokenUse // last sign-in per user ID
|
|
fails map[string]*failState // by lockKey
|
|
waiting int // sign-ins waiting for or running a password check
|
|
mfa mfaState
|
|
}
|
|
|
|
const (
|
|
maxFailures = 5
|
|
lockoutTime = 15 * time.Minute
|
|
// maxWaiting sign-ins may wait for a password check; more are turned
|
|
// away until the queue is shorter.
|
|
maxWaiting = 16
|
|
)
|
|
|
|
func newAuth(s *Store) *Auth {
|
|
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}, mfa: newMFAState()}
|
|
}
|
|
|
|
func cookieName() string { return appName + "_session" }
|
|
|
|
var (
|
|
errLocked = errors.New("too many failed attempts, try again later")
|
|
errBusy = errors.New("too many sign-ins at once, try again in a moment")
|
|
)
|
|
|
|
// lockKey is what failed sign-ins are counted by: the IPv4 address, or the
|
|
// /64 network of an IPv6 address, since one device can pick any address in
|
|
// its /64.
|
|
func lockKey(ip string) string {
|
|
a, err := netip.ParseAddr(ip)
|
|
if err != nil || a.Unmap().Is4() {
|
|
return ip
|
|
}
|
|
p, _ := a.Prefix(64)
|
|
return p.String()
|
|
}
|
|
|
|
// Login checks the credentials and returns a new session id, or, for a user
|
|
// with two-step sign-in, a ticket for the second step.
|
|
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
|
cfg := a.store.Get()
|
|
if !cfg.passwordSet() {
|
|
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
|
}
|
|
// The attempt counts as failed before the password is checked, so
|
|
// parallel attempts cannot get past the lockout; a right password takes
|
|
// it back.
|
|
a.mu.Lock()
|
|
if a.lockedLocked(ip) {
|
|
a.mu.Unlock()
|
|
return "", "", errLocked
|
|
}
|
|
if a.waiting >= maxWaiting {
|
|
a.mu.Unlock()
|
|
return "", "", errBusy
|
|
}
|
|
a.waiting++
|
|
undo := a.failLocked(ip)
|
|
a.mu.Unlock()
|
|
defer func() {
|
|
a.mu.Lock()
|
|
a.waiting--
|
|
a.mu.Unlock()
|
|
}()
|
|
|
|
// An unknown username costs as much time as a wrong password, so the
|
|
// answer time does not tell which usernames exist.
|
|
u := cfg.userByName(strings.TrimSpace(user))
|
|
okUser := u != nil && u.PasswordHash != ""
|
|
hash := dummyHash()
|
|
if okUser {
|
|
hash = u.PasswordHash
|
|
}
|
|
okPw := verifyPassword(hash, pw)
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if !okUser || !okPw {
|
|
return "", "", errors.New("wrong username or password")
|
|
}
|
|
undo()
|
|
if u.hasMFA() {
|
|
return "", a.newTicketLocked(u, ip), nil
|
|
}
|
|
delete(a.fails, lockKey(ip))
|
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
|
}
|
|
|
|
// NewSession replaces a session after the user changed their password; it
|
|
// keeps when and from where the old one started.
|
|
func (a *Auth) NewSession(u *User, info sessionInfo) string {
|
|
cfg := a.store.Get()
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.newSessionLocked(cfg, u, info)
|
|
}
|
|
|
|
func (a *Auth) newSessionLocked(cfg *Config, u *User, info sessionInfo) string {
|
|
id := randomString(32)
|
|
a.sessions[id] = &session{userID: u.ID, stamp: u.PasswordHash, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour), info: info}
|
|
return id
|
|
}
|
|
|
|
// LastLogin returns when the user last signed in since the service started.
|
|
func (a *Auth) LastLogin(userID string) *tokenUse {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if u, ok := a.logins[userID]; ok {
|
|
return &u
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var dummy struct {
|
|
once sync.Once
|
|
hash string
|
|
}
|
|
|
|
func dummyHash() string {
|
|
dummy.once.Do(func() { dummy.hash, _ = hashPassword(randomString(16)) })
|
|
return dummy.hash
|
|
}
|
|
|
|
func (a *Auth) Logout(id string) {
|
|
a.mu.Lock()
|
|
delete(a.sessions, id)
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func remoteIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
// Behind a local reverse proxy the real client is in X-Forwarded-For.
|
|
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
|
}
|
|
}
|
|
return host
|
|
}
|
|
|
|
// Authenticate accepts a session cookie or "Authorization: Bearer wgt_...".
|
|
func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
|
|
ip := remoteIP(r)
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
|
tok := strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
|
if !strings.HasPrefix(tok, tokenPrefix) {
|
|
return nil, false
|
|
}
|
|
want := hashToken(tok)
|
|
for _, t := range a.store.Get().APITokens {
|
|
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(want)) == 1 {
|
|
a.mu.Lock()
|
|
a.used[t.ID] = tokenUse{At: time.Now(), IP: ip}
|
|
a.mu.Unlock()
|
|
return &principal{Name: t.Name, UserID: t.UserID, Scope: t.Scope, TokenID: t.ID, RemoteIP: ip}, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
c, err := r.Cookie(cookieName())
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
cfg := a.store.Get()
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
s := a.sessions[c.Value]
|
|
if s == nil || time.Now().After(s.expires) {
|
|
delete(a.sessions, c.Value)
|
|
return nil, false
|
|
}
|
|
// The user is looked up on every request: a deleted user or a changed
|
|
// password ends the session at once.
|
|
_, u := cfg.userByID(s.userID)
|
|
if u == nil || u.PasswordHash != s.stamp {
|
|
delete(a.sessions, c.Value)
|
|
return nil, false
|
|
}
|
|
info := s.info
|
|
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword,
|
|
MFASetupRequired: cfg.SignIn.RequireMFA && !u.hasMFA(), Session: &info}, true
|
|
}
|
|
|
|
func (a *Auth) TokenUse(id string) *tokenUse {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if u, ok := a.used[id]; ok {
|
|
return &u
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sweep removes expired sessions and stale lockouts.
|
|
func (a *Auth) sweep() {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
now := time.Now()
|
|
for id, s := range a.sessions {
|
|
if now.After(s.expires) {
|
|
delete(a.sessions, id)
|
|
}
|
|
}
|
|
for key, f := range a.fails {
|
|
if now.After(f.until) && f.count == 0 {
|
|
delete(a.fails, key)
|
|
}
|
|
}
|
|
for id, t := range a.mfa.tickets {
|
|
if now.After(t.expires) {
|
|
delete(a.mfa.tickets, id)
|
|
}
|
|
}
|
|
for _, m := range []map[string]*ceremony{a.mfa.logins, a.mfa.enrolls} {
|
|
for id, c := range m {
|
|
if now.After(c.expires) {
|
|
delete(m, id)
|
|
}
|
|
}
|
|
}
|
|
}
|