Phase 16: hardening and release 0.1.0
- TOTP second factor (RFC 6238, secret encrypted under the master key), ten hashed single-use recovery codes, two-step login with a pending token bound to the client address, account enrolment/disable, admin reset - tests/security: prompt-injection corpus never acts or creates evidence-free beliefs; roles and CSRF on form and API paths; cross-mind isolation (demo scenario 5); stored-XSS escaping and CSP across pages; secrets never echoed (API, pages, database, archives); hostile archive import; session lifecycle and security headers - OpenAPI 3.1 document generated from the route table, served at /api/v1/openapi.json and kept complete by a test - gosec clean (justified #nosec annotations, cookie/redirect hardening, bounded multipart parsing, UUID packing without narrowing conversions); govulncheck clean with toolchain pinned to go1.26.8 - THREAT_MODEL verification pointers, SECURITY.md testing section, README, CHANGELOG 0.1.0, docs/RELEASE_NOTES.md, PLAN status
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenAPICoversEveryRoute keeps openapi.json honest: every /api/v1 route
|
||||
// registered in this package must be documented with its method.
|
||||
func TestOpenAPICoversEveryRoute(t *testing.T) {
|
||||
var doc struct {
|
||||
Paths map[string]map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(openAPI, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
re := regexp.MustCompile(`Handle(?:Func)?\("(GET|POST|PUT|PATCH|DELETE) (/api/v1[^"]*)"`)
|
||||
entries, _ := os.ReadDir(".")
|
||||
var missing []string
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, _ := os.ReadFile(e.Name())
|
||||
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
|
||||
method, path := strings.ToLower(m[1]), m[2]
|
||||
if path == "/api/v1/openapi.json" {
|
||||
continue
|
||||
}
|
||||
if _, ok := doc.Paths[path][method]; !ok {
|
||||
missing = append(missing, m[1]+" "+path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("routes missing from openapi.json (regenerate it):\n%s", strings.Join(missing, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
@@ -11,6 +12,10 @@ import (
|
||||
)
|
||||
|
||||
// Server composes the API routes, the web UI and the middleware chain.
|
||||
//
|
||||
//go:embed openapi.json
|
||||
var openAPI []byte
|
||||
|
||||
type Server struct {
|
||||
deps app.Deps
|
||||
mux *http.ServeMux
|
||||
@@ -42,6 +47,10 @@ func (s *Server) routes() {
|
||||
m.Handle("GET /metrics", require(auth.RoleObserver, promhttp.HandlerFor(s.deps.Metrics.Registry, promhttp.HandlerOpts{}).ServeHTTP))
|
||||
|
||||
m.Handle("GET /api/v1/system/status", require(auth.RoleObserver, s.systemStatus))
|
||||
m.HandleFunc("GET /api/v1/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(openAPI) //nolint:errcheck
|
||||
})
|
||||
m.Handle("GET /api/v1/audit", require(auth.RoleOperator, s.auditList))
|
||||
|
||||
m.Handle("GET /api/v1/users", require(auth.RoleAdministrator, s.usersList))
|
||||
|
||||
@@ -126,7 +126,7 @@ func ClientIP(r *http.Request, trustProxy bool) string {
|
||||
|
||||
// SetSessionCookie writes the session cookie with secure attributes.
|
||||
func SetSessionCookie(w http.ResponseWriter, token string, secure bool, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure,
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure, // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func VerifyPassword(hash, password string) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
||||
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) // #nosec G115 -- key length is 32
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/andydixon/noema/internal/security/secrets"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
@@ -60,6 +61,8 @@ type Service struct {
|
||||
absoluteTTL time.Duration
|
||||
limiter *ipLimiter
|
||||
now func() time.Time
|
||||
secrets *secrets.Resolver
|
||||
pending pendingStore
|
||||
}
|
||||
|
||||
// NewService creates the authentication service.
|
||||
@@ -68,7 +71,7 @@ func NewService(db *pgxpool.Pool, idleTTL, absoluteTTL time.Duration) *Service {
|
||||
}
|
||||
|
||||
// SweepLimiter releases idle rate-limit buckets; call periodically.
|
||||
func (s *Service) SweepLimiter() { s.limiter.Sweep() }
|
||||
func (s *Service) SweepLimiter() { s.limiter.Sweep(); s.pending.sweep(s.now()) }
|
||||
|
||||
// CreateUser adds an account. Usernames are unique case-insensitively.
|
||||
func (s *Service) CreateUser(ctx context.Context, username, password string, role Role) (User, error) {
|
||||
@@ -215,6 +218,12 @@ func (s *Service) Login(ctx context.Context, username, password, ip, userAgent s
|
||||
if _, err := s.db.Exec(ctx, `UPDATE users SET failed_attempts=0, locked_until=NULL WHERE id=$1`, u.ID); err != nil {
|
||||
return Session{}, "", err
|
||||
}
|
||||
if u.TOTPEnabled {
|
||||
// Password accepted; hand back a pending token instead of a session.
|
||||
token, hash := newToken()
|
||||
s.pending.put(string(hash), &pending{userID: u.ID, ip: clientKey(ip), ua: truncate(userAgent, 256), expires: s.now().Add(pendingTTL)})
|
||||
return Session{}, token, ErrSecondFactorRequired
|
||||
}
|
||||
return s.createSession(ctx, u.ID, ip, userAgent)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1" // #nosec G505 -- RFC 6238 TOTP mandates HMAC-SHA1; authenticator apps expect it
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/security/secrets"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
// Second factor (spec §26 / Phase 16): RFC 6238 TOTP with single-use
|
||||
// recovery codes. The TOTP secret is stored encrypted under the master key;
|
||||
// recovery codes are stored hashed. Login becomes two steps when enabled:
|
||||
// password first, then a short-lived pending token exchanged with a code.
|
||||
|
||||
// ErrSecondFactorRequired is returned by Login when the password was right
|
||||
// but the account requires a code; the returned token is the pending token.
|
||||
var ErrSecondFactorRequired = errors.New("second factor required")
|
||||
|
||||
// ErrTOTPUnavailable is returned when enrolment is impossible (no master key).
|
||||
var ErrTOTPUnavailable = errors.New("two-factor authentication needs NOEMA_MASTER_KEY so the secret can be stored encrypted")
|
||||
|
||||
const (
|
||||
totpPeriod = 30
|
||||
totpDigits = 6
|
||||
totpSkew = 1 // accept ±1 period
|
||||
pendingTTL = 5 * time.Minute
|
||||
recoveryN = 10
|
||||
recoveryBits = 10 // bytes → 16 base32 chars
|
||||
)
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// GenerateTOTPSecret returns a fresh 160-bit base32 secret.
|
||||
func GenerateTOTPSecret() (string, error) {
|
||||
buf := make([]byte, 20)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b32.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// TOTPCode computes the code for a secret at time t.
|
||||
func TOTPCode(secret string, t time.Time) (string, error) {
|
||||
key, err := b32.DecodeString(strings.ToUpper(strings.ReplaceAll(secret, " ", "")))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
counter := uint64(t.Unix() / totpPeriod) // #nosec G115 -- Unix time after 1970 is non-negative
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], counter)
|
||||
mac := hmac.New(sha1.New, key)
|
||||
mac.Write(msg[:])
|
||||
sum := mac.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
bin := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||||
return fmt.Sprintf("%06d", bin%1_000_000), nil
|
||||
}
|
||||
|
||||
// VerifyTOTP checks a code against the secret within ±totpSkew periods.
|
||||
func VerifyTOTP(secret, code string, now time.Time) bool {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != totpDigits {
|
||||
return false
|
||||
}
|
||||
ok := false
|
||||
for i := -totpSkew; i <= totpSkew; i++ {
|
||||
want, err := TOTPCode(secret, now.Add(time.Duration(i)*totpPeriod*time.Second))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||
ok = true // keep looping: constant work regardless of which window matched
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// OTPAuthURL renders the standard enrolment URI for authenticator apps.
|
||||
func OTPAuthURL(issuer, account, secret string) string {
|
||||
label := url.PathEscape(issuer + ":" + account)
|
||||
q := url.Values{"secret": {secret}, "issuer": {issuer}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}}
|
||||
return "otpauth://totp/" + label + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// newRecoveryCodes returns plaintext codes and their hashes.
|
||||
func newRecoveryCodes() ([]string, [][]byte, error) {
|
||||
var plain []string
|
||||
var hashes [][]byte
|
||||
for i := 0; i < recoveryN; i++ {
|
||||
buf := make([]byte, recoveryBits)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
c := strings.ToLower(b32.EncodeToString(buf))
|
||||
c = c[:4] + "-" + c[4:8] + "-" + c[8:12] + "-" + c[12:16]
|
||||
plain = append(plain, c)
|
||||
hashes = append(hashes, hashRecovery(c))
|
||||
}
|
||||
return plain, hashes, nil
|
||||
}
|
||||
|
||||
func hashRecovery(code string) []byte {
|
||||
norm := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(code), "-", ""), " ", ""))
|
||||
h := sha256.Sum256([]byte("noema-recovery:" + norm))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// pending holds password-verified logins awaiting a second factor.
|
||||
type pending struct {
|
||||
userID ids.ID
|
||||
ip string
|
||||
ua string
|
||||
expires time.Time
|
||||
tries int
|
||||
}
|
||||
|
||||
type pendingStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*pending
|
||||
}
|
||||
|
||||
func (p *pendingStore) put(hash string, v *pending) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.m == nil {
|
||||
p.m = map[string]*pending{}
|
||||
}
|
||||
p.m[hash] = v
|
||||
}
|
||||
|
||||
func (p *pendingStore) get(hash string, now time.Time) (*pending, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
v, ok := p.m[hash]
|
||||
if !ok || now.After(v.expires) {
|
||||
delete(p.m, hash)
|
||||
return nil, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (p *pendingStore) del(hash string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.m, hash)
|
||||
}
|
||||
|
||||
func (p *pendingStore) sweep(now time.Time) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for k, v := range p.m {
|
||||
if now.After(v.expires) {
|
||||
delete(p.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetSecrets provides the resolver used to encrypt TOTP secrets at rest.
|
||||
func (s *Service) SetSecrets(r *secrets.Resolver) { s.secrets = r }
|
||||
|
||||
func (s *Service) totpSecret(ctx context.Context, userID ids.ID) (string, bool, error) {
|
||||
var enc *string
|
||||
var enabled bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT totp_secret_enc, totp_enabled FROM users WHERE id=$1`, userID).Scan(&enc, &enabled); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if enc == nil || *enc == "" {
|
||||
return "", enabled, nil
|
||||
}
|
||||
if s.secrets == nil {
|
||||
return "", enabled, ErrTOTPUnavailable
|
||||
}
|
||||
plain, err := s.secrets.Resolve(*enc)
|
||||
if err != nil {
|
||||
return "", enabled, err
|
||||
}
|
||||
return plain, enabled, nil
|
||||
}
|
||||
|
||||
// BeginTOTP creates a pending secret for the user (not yet enforced) and
|
||||
// returns it with the otpauth URI so the operator can add it to an app.
|
||||
func (s *Service) BeginTOTP(ctx context.Context, userID ids.ID, issuer string) (secret, uri string, err error) {
|
||||
if s.secrets == nil || !s.secrets.CanEncrypt() {
|
||||
return "", "", ErrTOTPUnavailable
|
||||
}
|
||||
u, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
secret, err = GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
enc, err := s.secrets.Encrypt(secret)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `UPDATE users SET totp_secret_enc=$2, totp_enabled=false, updated_at=now() WHERE id=$1`, userID, enc); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return secret, OTPAuthURL(issuer, u.Username, secret), nil
|
||||
}
|
||||
|
||||
// PendingTOTP returns the not-yet-confirmed secret and URI so the account
|
||||
// page can show them again after a redirect without a second enrolment.
|
||||
func (s *Service) PendingTOTP(ctx context.Context, userID ids.ID, issuer string) (secret, uri string, err error) {
|
||||
secret, enabled, err := s.totpSecret(ctx, userID)
|
||||
if err != nil || secret == "" || enabled {
|
||||
return "", "", errors.New("no pending enrolment")
|
||||
}
|
||||
u, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return secret, OTPAuthURL(issuer, u.Username, secret), nil
|
||||
}
|
||||
|
||||
// ConfirmTOTP verifies a first code against the pending secret, turns the
|
||||
// second factor on and returns freshly generated recovery codes (shown once).
|
||||
func (s *Service) ConfirmTOTP(ctx context.Context, userID ids.ID, code string) ([]string, error) {
|
||||
secret, _, err := s.totpSecret(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, errors.New("no pending enrolment")
|
||||
}
|
||||
if !VerifyTOTP(secret, code, s.now()) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
plain, hashes, err := newRecoveryCodes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM recovery_codes WHERE user_id=$1`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, h := range hashes {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO recovery_codes (id, user_id, code_hash) VALUES ($1,$2,$3)`, ids.New(), userID, h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE users SET totp_enabled=true, updated_at=now() WHERE id=$1`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plain, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// DisableTOTP removes the second factor and all recovery codes.
|
||||
func (s *Service) DisableTOTP(ctx context.Context, userID ids.ID) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM recovery_codes WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE users SET totp_secret_enc=NULL, totp_enabled=false, updated_at=now() WHERE id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// RecoveryCodesLeft counts unused recovery codes.
|
||||
func (s *Service) RecoveryCodesLeft(ctx context.Context, userID ids.ID) int {
|
||||
var n int
|
||||
s.db.QueryRow(ctx, `SELECT count(*) FROM recovery_codes WHERE user_id=$1 AND used_at IS NULL`, userID).Scan(&n) //nolint:errcheck
|
||||
return n
|
||||
}
|
||||
|
||||
// verifySecondFactor accepts a TOTP code or an unused recovery code.
|
||||
func (s *Service) verifySecondFactor(ctx context.Context, userID ids.ID, code string) (usedRecovery bool, err error) {
|
||||
secret, enabled, err := s.totpSecret(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !enabled {
|
||||
return false, nil
|
||||
}
|
||||
if VerifyTOTP(secret, code, s.now()) {
|
||||
return false, nil
|
||||
}
|
||||
if len(strings.TrimSpace(code)) >= 16 {
|
||||
tag, err := s.db.Exec(ctx, `UPDATE recovery_codes SET used_at=now() WHERE user_id=$1 AND code_hash=$2 AND used_at IS NULL`, userID, hashRecovery(code))
|
||||
if err == nil && tag.RowsAffected() == 1 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// CompleteLogin exchanges a pending token plus a second-factor code for a
|
||||
// session. Five wrong codes void the pending login.
|
||||
func (s *Service) CompleteLogin(ctx context.Context, pendingToken, code, ip, userAgent string) (Session, string, bool, error) {
|
||||
if !s.limiter.Allow(clientKey(ip)) {
|
||||
return Session{}, "", false, ErrRateLimited
|
||||
}
|
||||
hash := string(hashToken(pendingToken))
|
||||
p, ok := s.pending.get(hash, s.now())
|
||||
if !ok || p.ip != clientKey(ip) {
|
||||
return Session{}, "", false, ErrInvalidCredentials
|
||||
}
|
||||
usedRecovery, err := s.verifySecondFactor(ctx, p.userID, code)
|
||||
if err != nil {
|
||||
p.tries++
|
||||
if p.tries >= 5 {
|
||||
s.pending.del(hash)
|
||||
}
|
||||
return Session{}, "", false, err
|
||||
}
|
||||
s.pending.del(hash)
|
||||
sess, tok, err := s.createSession(ctx, p.userID, ip, userAgent)
|
||||
return sess, tok, usedRecovery, err
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/noema/internal/security/secrets"
|
||||
"github.com/andydixon/noema/internal/storage"
|
||||
"github.com/andydixon/noema/migrations"
|
||||
)
|
||||
|
||||
func TestTOTPVectors(t *testing.T) {
|
||||
// RFC 6238 test vector: secret "12345678901234567890" (base32 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ), T=59 → 287082 (SHA1, 8 digits 94287082).
|
||||
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
code, err := TOTPCode(secret, time.Unix(59, 0))
|
||||
if err != nil || code != "287082" {
|
||||
t.Fatalf("got %q %v", code, err)
|
||||
}
|
||||
if !VerifyTOTP(secret, "287082", time.Unix(59, 0)) || !VerifyTOTP(secret, "287082", time.Unix(59+30, 0)) || VerifyTOTP(secret, "287082", time.Unix(59+120, 0)) {
|
||||
t.Fatal("verification window wrong")
|
||||
}
|
||||
if VerifyTOTP(secret, "28708", time.Unix(59, 0)) || VerifyTOTP(secret, "000000", time.Unix(59, 0)) {
|
||||
t.Fatal("bad code accepted")
|
||||
}
|
||||
if u := OTPAuthURL("Noema", "andy", secret); u != "otpauth://totp/Noema:andy?algorithm=SHA1&digits=6&issuer=Noema&period=30&secret="+secret {
|
||||
t.Fatal(u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoStepLoginAndRecovery(t *testing.T) {
|
||||
pool := storage.TestPool(t, migrations.FS)
|
||||
ctx := context.Background()
|
||||
svc := NewService(pool, time.Hour, 2*time.Hour)
|
||||
sec, _ := secrets.New("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") // 32 bytes base64
|
||||
svc.SetSecrets(sec)
|
||||
u, err := svc.CreateUser(ctx, "andy", "a-long-enough-password", RoleAdministrator)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Without a master key enrolment is refused.
|
||||
if _, _, err := NewService(pool, time.Hour, time.Hour).BeginTOTP(ctx, u.ID, "Noema"); !errors.Is(err, ErrTOTPUnavailable) {
|
||||
t.Fatalf("expected unavailable, got %v", err)
|
||||
}
|
||||
secret, uri, err := svc.BeginTOTP(ctx, u.ID, "Noema")
|
||||
if err != nil || secret == "" || uri == "" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pending enrolment does not change login yet.
|
||||
if _, _, err := svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t"); err != nil {
|
||||
t.Fatalf("login before confirm: %v", err)
|
||||
}
|
||||
if _, err := svc.ConfirmTOTP(ctx, u.ID, "000000"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("wrong code accepted: %v", err)
|
||||
}
|
||||
code, _ := TOTPCode(secret, time.Now())
|
||||
codes, err := svc.ConfirmTOTP(ctx, u.ID, code)
|
||||
if err != nil || len(codes) != 10 {
|
||||
t.Fatalf("confirm: %v %d", err, len(codes))
|
||||
}
|
||||
// Login now stops at the second factor.
|
||||
_, pendingTok, err := svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t")
|
||||
if !errors.Is(err, ErrSecondFactorRequired) || pendingTok == "" {
|
||||
t.Fatalf("expected second factor, got %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, "000000", "10.0.0.1", "t"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("wrong code completed login: %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, code, "10.0.0.2", "t"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatal("pending token accepted from another address")
|
||||
}
|
||||
sess, tok, usedRecovery, err := svc.CompleteLogin(ctx, pendingTok, code, "10.0.0.1", "t")
|
||||
if err != nil || tok == "" || usedRecovery || sess.UserID != u.ID {
|
||||
t.Fatalf("complete: %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, code, "10.0.0.1", "t"); err == nil {
|
||||
t.Fatal("pending token reusable")
|
||||
}
|
||||
// Recovery code: works once.
|
||||
_, pendingTok, _ = svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t")
|
||||
if _, _, used, err := svc.CompleteLogin(ctx, pendingTok, codes[0], "10.0.0.1", "t"); err != nil || !used {
|
||||
t.Fatalf("recovery: %v", err)
|
||||
}
|
||||
if svc.RecoveryCodesLeft(ctx, u.ID) != 9 {
|
||||
t.Fatal("recovery code not consumed")
|
||||
}
|
||||
_, pendingTok, _ = svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t")
|
||||
if _, _, _, err := svc.CompleteLogin(ctx, pendingTok, codes[0], "10.0.0.1", "t"); err == nil {
|
||||
t.Fatal("recovery code reused")
|
||||
}
|
||||
// Disable restores single-step login and wipes codes.
|
||||
if err := svc.DisableTOTP(ctx, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Login(ctx, "andy", "a-long-enough-password", "10.0.0.1", "t"); err != nil {
|
||||
t.Fatalf("login after disable: %v", err)
|
||||
}
|
||||
if svc.RecoveryCodesLeft(ctx, u.ID) != 0 {
|
||||
t.Fatal("codes survived disable")
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ type State struct {
|
||||
// AllowLLM reserves one model call against the mind's per-cycle cap and
|
||||
// reports whether it was granted, so a chatty cycle cannot exceed the budget.
|
||||
func (s *State) AllowLLM() bool {
|
||||
cap := int32(s.Mind.Config.LLMCallsPerCycle)
|
||||
cap := int32(s.Mind.Config.LLMCallsPerCycle) // #nosec G115 -- config is normalised to a small non-negative range
|
||||
if cap <= 0 || s.Services.LLM == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func Defaults() Config {
|
||||
func Load(path string, lookup func(string) (string, bool)) (Config, error) {
|
||||
c := Defaults()
|
||||
if path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
b, err := os.ReadFile(path) // #nosec G304 -- operator-supplied config path from the command line
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("config: read %s: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func (g GitPoller) git(ctx context.Context, dir string, args ...string) (string,
|
||||
cctx, cancel := context.WithTimeout(ctx, t)
|
||||
defer cancel()
|
||||
full := append([]string{"-c", "core.hooksPath=/dev/null", "-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=always"}, args...)
|
||||
cmd := exec.CommandContext(cctx, "git", full...)
|
||||
cmd := exec.CommandContext(cctx, "git", full...) // #nosec G204 -- argv only, fixed binary, no shell; arguments are validated URLs, refs and paths
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func (u *UI) conversationPost(w http.ResponseWriter, r *http.Request) {
|
||||
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/conversations/"+id.String())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/minds/"+m.Slug+"/conversations/"+id.String(), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/minds/"+m.Slug+"/conversations/"+id.String(), http.StatusSeeOther) // #nosec G710 -- path built from a validated slug and a parsed id
|
||||
}
|
||||
|
||||
func prettyRaw(raw json.RawMessage) string { return pretty(raw) }
|
||||
|
||||
@@ -123,5 +123,5 @@ func (u *UI) dreamPause(w http.ResponseWriter, r *http.Request) {
|
||||
paused := r.PostFormValue("paused") == "1"
|
||||
dreaming.SetPaused(paused)
|
||||
u.deps.Audit.Log(r.Context(), "dream.pause", "system", "", map[string]any{"paused": paused})
|
||||
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/minds", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
+105
-4
@@ -32,6 +32,12 @@ func (u *UI) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ip := auth.ClientIP(r, false)
|
||||
sess, token, err := u.deps.Auth.Login(r.Context(), r.PostFormValue("username"), r.PostFormValue("password"), ip, r.UserAgent())
|
||||
if errors.Is(err, auth.ErrSecondFactorRequired) {
|
||||
// Password accepted; the pending token lives in a short cookie scoped to the second step.
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_2fa", Value: token, Path: "/login/totp", MaxAge: 300, HttpOnly: true, Secure: u.deps.Config.Production(), SameSite: http.SameSiteStrictMode}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
u.render(w, r, "login_totp", "Second factor", map[string]any{"Next": safeNext(r.PostFormValue("next"))})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
u.deps.Metrics.AuthFailures.Inc()
|
||||
msg := "Invalid username or password."
|
||||
@@ -55,7 +61,41 @@ func (u *UI) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if to == "" {
|
||||
to = "/"
|
||||
}
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
http.Redirect(w, r, to, http.StatusSeeOther) // #nosec G710 -- target is a validated same-origin path (safeNext / fixed prefix)
|
||||
}
|
||||
|
||||
// loginTOTP completes a two-step login with a TOTP or recovery code.
|
||||
func (u *UI) loginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("noema_2fa")
|
||||
if err != nil || r.ParseForm() != nil {
|
||||
u.flash(w, r, "error", "Sign in again; the second step expired.", "/login")
|
||||
return
|
||||
}
|
||||
ip := auth.ClientIP(r, false)
|
||||
sess, token, usedRecovery, err := u.deps.Auth.CompleteLogin(r.Context(), c.Value, r.PostFormValue("code"), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
u.deps.Metrics.AuthFailures.Inc()
|
||||
u.deps.Audit.Log(r.Context(), "login.failed", "user", "", map[string]any{"reason": "second factor: " + err.Error()})
|
||||
msg := "That code was not accepted."
|
||||
if errors.Is(err, auth.ErrRateLimited) {
|
||||
msg = "Too many attempts from this address. Slow down."
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
u.render(w, r, "login_totp", "Second factor", map[string]any{"Error": msg, "Next": safeNext(r.PostFormValue("next"))})
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_2fa", Path: "/login/totp", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
auth.SetSessionCookie(w, token, u.deps.Config.Production(), int(u.deps.Config.SessionAbsoluteTTL.Seconds()))
|
||||
ctx := auth.WithPrincipal(r.Context(), auth.Principal{User: auth.User{ID: sess.UserID}, SessionID: sess.ID})
|
||||
u.deps.Audit.Log(ctx, "login.success", "session", sess.ID.String(), map[string]any{"second_factor": true, "recovery_code": usedRecovery})
|
||||
to := safeNext(r.PostFormValue("next"))
|
||||
if to == "" {
|
||||
to = "/"
|
||||
}
|
||||
if usedRecovery {
|
||||
to = "/account"
|
||||
}
|
||||
http.Redirect(w, r, to, http.StatusSeeOther) // #nosec G710 -- target is a validated same-origin path (safeNext / fixed prefix)
|
||||
}
|
||||
|
||||
func (u *UI) logout(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -138,9 +178,64 @@ func (u *UI) account(w http.ResponseWriter, r *http.Request) {
|
||||
var newToken string
|
||||
if c, err := r.Cookie("noema_newtoken"); err == nil {
|
||||
newToken = c.Value
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Path: "/account", MaxAge: -1, HttpOnly: true})
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Path: "/account", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
}
|
||||
u.render(w, r, "account", "Account", map[string]any{"Sessions": sessions, "Tokens": tokens, "NewToken": newToken})
|
||||
me, _ := u.deps.Auth.GetUser(r.Context(), p.User.ID)
|
||||
data := map[string]any{"Sessions": sessions, "Tokens": tokens, "NewToken": newToken, "TOTPEnabled": me.TOTPEnabled, "TOTPAvailable": u.deps.Secrets != nil && u.deps.Secrets.CanEncrypt()}
|
||||
if me.TOTPEnabled {
|
||||
data["RecoveryLeft"] = u.deps.Auth.RecoveryCodesLeft(r.Context(), p.User.ID)
|
||||
}
|
||||
if c, err := r.Cookie("noema_totp_setup"); err == nil {
|
||||
// Enrolment in progress: the cookie only marks the state; the secret is re-derived server-side.
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_totp_setup", Path: "/account", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
data["Enrolling"] = c.Value == "1"
|
||||
if secret, uri, err := u.deps.Auth.PendingTOTP(r.Context(), p.User.ID, "Noema"); err == nil {
|
||||
data["TOTPSecret"], data["TOTPURI"] = secret, uri
|
||||
}
|
||||
}
|
||||
if c, err := r.Cookie("noema_recovery"); err == nil {
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_recovery", Path: "/account", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
data["RecoveryCodes"] = strings.Split(c.Value, ".")
|
||||
}
|
||||
u.render(w, r, "account", "Account", data)
|
||||
}
|
||||
|
||||
func (u *UI) accountTOTPBegin(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
if _, _, err := u.deps.Auth.BeginTOTP(r.Context(), p.User.ID, "Noema"); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/account")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_totp_setup", Value: "1", Path: "/account", MaxAge: 600, HttpOnly: true, Secure: u.deps.Config.Production(), SameSite: http.SameSiteStrictMode}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
http.Redirect(w, r, "/account#totp", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) accountTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
codes, err := u.deps.Auth.ConfirmTOTP(r.Context(), p.User.ID, r.PostFormValue("code"))
|
||||
if err != nil {
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_totp_setup", Value: "1", Path: "/account", MaxAge: 600, HttpOnly: true, Secure: u.deps.Config.Production(), SameSite: http.SameSiteStrictMode}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
u.flash(w, r, "error", "Code not accepted; try the next one from your app.", "/account#totp")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.totp.enabled", "user", p.User.ID.String(), nil)
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_recovery", Value: strings.Join(codes, "."), Path: "/account", MaxAge: 300, HttpOnly: true, Secure: u.deps.Config.Production(), SameSite: http.SameSiteStrictMode}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
http.Redirect(w, r, "/account#totp", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) accountTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
p, _ := auth.PrincipalFrom(r.Context())
|
||||
ip := auth.ClientIP(r, false)
|
||||
if _, _, err := u.deps.Auth.Login(r.Context(), p.User.Username, r.PostFormValue("current"), ip, r.UserAgent()); err != nil && !errors.Is(err, auth.ErrSecondFactorRequired) {
|
||||
u.flash(w, r, "error", "Current password incorrect.", "/account#totp")
|
||||
return
|
||||
}
|
||||
if err := u.deps.Auth.DisableTOTP(r.Context(), p.User.ID); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/account#totp")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.totp.disabled", "user", p.User.ID.String(), nil)
|
||||
u.flash(w, r, "ok", "Two-factor authentication disabled.", "/account#totp")
|
||||
}
|
||||
|
||||
func (u *UI) accountPassword(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -189,7 +284,7 @@ func (u *UI) accountTokenCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": name})
|
||||
// Shown exactly once on the next page load, then the cookie is cleared.
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Value: tok, Path: "/account", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()})
|
||||
http.SetCookie(w, &http.Cookie{Name: "noema_newtoken", Value: tok, Path: "/account", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: u.deps.Config.Production()}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -248,6 +343,12 @@ func (u *UI) usersUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": role})
|
||||
case "reset-totp":
|
||||
if err := u.deps.Auth.DisableTOTP(r.Context(), id); err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/admin/users")
|
||||
return
|
||||
}
|
||||
u.deps.Audit.Log(r.Context(), "user.totp.reset", "user", id.String(), nil)
|
||||
case "disable", "enable":
|
||||
disabled := r.PostFormValue("action") == "disable"
|
||||
if err := u.deps.Auth.SetUserDisabled(r.Context(), id, disabled); err != nil {
|
||||
|
||||
@@ -112,7 +112,8 @@ func (u *UI) snapshotDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (u *UI) importArchive(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, snapshots.MaxArchiveBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil { // #nosec G120 -- body bounded by MaxBytesReader on the previous line
|
||||
u.flash(w, r, "error", "Upload too large or malformed.", "/minds")
|
||||
return
|
||||
}
|
||||
|
||||
+7
-3
@@ -307,7 +307,7 @@ func (u *UI) render(w http.ResponseWriter, r *http.Request, page, title string,
|
||||
v.Flash = &Flash{Kind: kind, Message: msg}
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{Name: flashCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: u.deps.Config.Production()}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, "layout", v); err != nil {
|
||||
@@ -323,8 +323,8 @@ func (u *UI) render(w http.ResponseWriter, r *http.Request, page, title string,
|
||||
// flash stores a one-shot message and redirects.
|
||||
func (u *UI) flash(w http.ResponseWriter, r *http.Request, kind, msg, to string) {
|
||||
val := base64.RawURLEncoding.EncodeToString([]byte(kind + "|" + msg))
|
||||
http.SetCookie(w, &http.Cookie{Name: flashCookie, Value: val, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: u.deps.Config.Production()})
|
||||
http.Redirect(w, r, to, http.StatusSeeOther)
|
||||
http.SetCookie(w, &http.Cookie{Name: flashCookie, Value: val, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: u.deps.Config.Production()}) // #nosec G124 -- Secure follows NOEMA_ENV=production; development runs over plain HTTP
|
||||
http.Redirect(w, r, to, http.StatusSeeOther) // #nosec G710 -- target is a validated same-origin path (safeNext / fixed prefix)
|
||||
}
|
||||
|
||||
func (u *UI) errorPage(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
@@ -354,6 +354,7 @@ func (u *UI) Register(m *http.ServeMux) {
|
||||
m.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(u.static))))
|
||||
m.HandleFunc("GET /login", u.loginForm)
|
||||
m.HandleFunc("POST /login", u.loginSubmit)
|
||||
m.HandleFunc("POST /login/totp", u.loginTOTP)
|
||||
m.HandleFunc("POST /logout", u.logout)
|
||||
m.HandleFunc("GET /setup", u.setupForm)
|
||||
m.HandleFunc("POST /setup", u.setupSubmit)
|
||||
@@ -364,6 +365,9 @@ func (u *UI) Register(m *http.ServeMux) {
|
||||
m.Handle("GET /audit", u.require(auth.RoleOperator, u.audit))
|
||||
m.Handle("GET /account", u.require(auth.RoleObserver, u.account))
|
||||
m.Handle("POST /account/password", u.require(auth.RoleObserver, u.accountPassword))
|
||||
m.Handle("POST /account/totp/begin", u.require(auth.RoleObserver, u.accountTOTPBegin))
|
||||
m.Handle("POST /account/totp/confirm", u.require(auth.RoleObserver, u.accountTOTPConfirm))
|
||||
m.Handle("POST /account/totp/disable", u.require(auth.RoleObserver, u.accountTOTPDisable))
|
||||
m.Handle("POST /account/sessions/{id}/revoke", u.require(auth.RoleObserver, u.accountSessionRevoke))
|
||||
m.Handle("POST /account/tokens", u.require(auth.RoleObserver, u.accountTokenCreate))
|
||||
m.Handle("POST /account/tokens/{id}/revoke", u.require(auth.RoleObserver, u.accountTokenRevoke))
|
||||
|
||||
Reference in New Issue
Block a user