feat(auth): Argon2id passwords, opaque tokens, TOTP and recovery codes
Password hashes are self-describing PHC strings, so the cost parameters can be raised later without locking anyone out: NeedsRehash reports which hashes to upgrade at the next login, when the plaintext is briefly at hand. Session, invite, reset and API tokens are stored only as SHA-256 hashes; the plaintext exists once, in the link or the reply. Unsalted is correct here and only here — the input is 256 bits of randomness that has to be found by value in a WHERE clause. feat(store): project roles with viewer/editor/owner and explicit denial Roles are strictly ordered so every check is "at least X". Access to a task is access to its project: being the assignee or the author confers nothing, or removing someone from a project would leave them holding tasks inside it. Checks take a Querier so they can run in the same transaction as the write they guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6a44244c1
commit
b44db32bf7
@@ -0,0 +1,136 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
// TOTP settings: RFC 6238 defaults, which is what every authenticator app assumes
|
||||
// when a QR code does not say otherwise.
|
||||
const (
|
||||
totpDigits = otp.DigitsSix
|
||||
totpPeriod = 30 // seconds
|
||||
// A one-step window either side, so a code entered as it rolls over — or on a
|
||||
// phone whose clock is a few seconds out — is still accepted. Wider than this
|
||||
// starts meaningfully extending the life of a stolen code.
|
||||
totpSkew = 1
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCode = errors.New("auth: verification code is not valid")
|
||||
ErrNoTOTPSecret = errors.New("auth: two-factor authentication is not set up")
|
||||
recoveryCodeLen = 10
|
||||
recoveryCodeSets = 8
|
||||
)
|
||||
|
||||
// NewTOTPSecret creates a secret for a user and the otpauth:// URI to render as a
|
||||
// QR code. Issuer and account are what the authenticator app shows in its list, so
|
||||
// they have to identify this verdande instance rather than just say "verdande" —
|
||||
// somebody self-hosting two of them needs to tell the entries apart.
|
||||
func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: account,
|
||||
Period: totpPeriod,
|
||||
Digits: totpDigits,
|
||||
Algorithm: otp.AlgorithmSHA1, // what authenticator apps actually implement
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate totp secret: %w", err)
|
||||
}
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||
// code again before enrolment is confirmed.
|
||||
func TOTPURI(secret, issuer, account string) string {
|
||||
v := url.Values{}
|
||||
v.Set("secret", secret)
|
||||
v.Set("issuer", issuer)
|
||||
v.Set("algorithm", "SHA1")
|
||||
v.Set("digits", "6")
|
||||
v.Set("period", fmt.Sprint(totpPeriod))
|
||||
return "otpauth://totp/" + url.PathEscape(issuer+":"+account) + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// VerifyTOTP checks a code against a secret at the given moment.
|
||||
func VerifyTOTP(secret, code string, now time.Time) error {
|
||||
if secret == "" {
|
||||
return ErrNoTOTPSecret
|
||||
}
|
||||
// Authenticator apps display codes in groups ("123 456"); people paste what
|
||||
// they see.
|
||||
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||
|
||||
ok, err := totp.ValidateCustom(code, secret, now, totp.ValidateOpts{
|
||||
Period: totpPeriod,
|
||||
Skew: totpSkew,
|
||||
Digits: totpDigits,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
return ErrInvalidCode
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
||||
// and the hashes to store. Without these, losing a device means losing the account,
|
||||
// and the only fix left is editing the database by hand.
|
||||
//
|
||||
// The plaintext is returned once, to be shown once. Only the hashes are stored.
|
||||
func NewRecoveryCodes() (codes []string, hashes []string, err error) {
|
||||
// Crockford-ish base32 without padding: unambiguous when read off a printout
|
||||
// and typed back in.
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
for i := 0; i < recoveryCodeSets; i++ {
|
||||
b := make([]byte, recoveryCodeLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, nil, fmt.Errorf("auth: read random: %w", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for j, v := range b {
|
||||
if j == recoveryCodeLen/2 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
sb.WriteByte(alphabet[int(v)%len(alphabet)])
|
||||
}
|
||||
code := sb.String()
|
||||
codes = append(codes, code)
|
||||
hashes = append(hashes, HashToken(normalizeRecoveryCode(code)))
|
||||
}
|
||||
return codes, hashes, nil
|
||||
}
|
||||
|
||||
// MatchRecoveryCode finds which stored hash a typed code corresponds to, or -1.
|
||||
// The caller must delete that hash: a recovery code works exactly once.
|
||||
func MatchRecoveryCode(hashes []string, code string) int {
|
||||
want := HashToken(normalizeRecoveryCode(code))
|
||||
for i, h := range hashes {
|
||||
if h == want {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// normalizeRecoveryCode forgives how the code was transcribed — case, the dash,
|
||||
// surrounding space — so a correct code is never rejected for its formatting.
|
||||
func normalizeRecoveryCode(code string) string {
|
||||
r := strings.NewReplacer("-", "", " ", "", "\t", "")
|
||||
return strings.ToUpper(r.Replace(strings.TrimSpace(code)))
|
||||
}
|
||||
|
||||
// DecodeTOTPSecret is used by tests and by the enrolment check to confirm a secret
|
||||
// is well-formed base32 before it is written to the database.
|
||||
func DecodeTOTPSecret(secret string) ([]byte, error) {
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
|
||||
}
|
||||
Reference in New Issue
Block a user