WireGuard server with an embedded admin console
Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes.
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
)
|
||||
|
||||
const cookieName = "wgx_session"
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
)
|
||||
|
||||
// userOf returns the authenticated user for a request.
|
||||
func userOf(r *http.Request) *store.User {
|
||||
u, _ := r.Context().Value(ctxUser).(*store.User)
|
||||
return u
|
||||
}
|
||||
|
||||
func sessionOf(r *http.Request) *store.Session {
|
||||
s, _ := r.Context().Value(ctxSession).(*store.Session)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) setCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
}
|
||||
|
||||
// loadSession resolves the cookie to a user, if the session is valid.
|
||||
func (s *Server) loadSession(r *http.Request) (*store.User, *store.Session) {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess, err := s.eng.Store().SessionByHash(ctx, auth.HashToken(c.Value))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(sess.ExpiresAt) || now.Sub(sess.CreatedAt) > s.cfg.SessionMax {
|
||||
_ = s.eng.Store().DeleteSession(ctx, sess.TokenHash)
|
||||
return nil, nil
|
||||
}
|
||||
u, err := s.eng.Store().UserByID(ctx, sess.UserID)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Slide the idle expiry, but not on every request: once a minute is
|
||||
// plenty and keeps the write load off SQLite.
|
||||
if now.Sub(sess.LastSeenAt) > time.Minute {
|
||||
_ = s.eng.Store().TouchSession(ctx, sess.TokenHash, now.Add(s.cfg.SessionIdle))
|
||||
}
|
||||
return u, sess
|
||||
}
|
||||
|
||||
// authed requires a fully authenticated session.
|
||||
func (s *Server) authed(next http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
u, sess := s.loadSession(r)
|
||||
if u == nil || sess.TOTPPending {
|
||||
writeError(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxUser, u)
|
||||
ctx = context.WithValue(ctx, ctxSession, sess)
|
||||
next(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// admin additionally requires the admin role.
|
||||
func (s *Server) admin(next http.HandlerFunc) http.Handler {
|
||||
return s.authed(func(w http.ResponseWriter, r *http.Request) {
|
||||
if userOf(r).Role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "administrator role required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) audit(r *http.Request, action, target, detail string) {
|
||||
actor := "-"
|
||||
if u := userOf(r); u != nil {
|
||||
actor = u.Username
|
||||
}
|
||||
if err := s.eng.Store().Audit(r.Context(), store.AuditEntry{Actor: actor, Action: action, Target: target, Detail: detail, IP: s.clientIP(r)}); err != nil {
|
||||
s.log.Warn("audit write failed", "error", err)
|
||||
}
|
||||
s.log.Info("audit", "actor", actor, "action", action, "target", target, "detail", detail, "ip", s.clientIP(r))
|
||||
}
|
||||
|
||||
// --- setup -----------------------------------------------------------------
|
||||
|
||||
type setupStatus struct {
|
||||
NeedsSetup bool `json:"needsSetup"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.eng.Store().CountUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, setupStatus{NeedsSetup: n == 0})
|
||||
}
|
||||
|
||||
type setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
EndpointHost string `json:"endpointHost"`
|
||||
}
|
||||
|
||||
func validUsername(u string) bool {
|
||||
if len(u) < 2 || len(u) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range u {
|
||||
if !(r == '.' || r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleSetup creates the first administrator. It only works while there
|
||||
// are no users at all, so a running server cannot be taken over by it.
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
n, err := s.eng.Store().CountUsers(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
writeError(w, http.StatusConflict, "setup has already been completed")
|
||||
return
|
||||
}
|
||||
var req setupRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if !validUsername(req.Username) {
|
||||
writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
settings := s.eng.Settings()
|
||||
if host := strings.TrimSpace(req.EndpointHost); host != "" {
|
||||
settings.EndpointHost = host
|
||||
}
|
||||
if err := settings.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.eng.Store().CreateUser(ctx, req.Username, hash, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.UpdateSettings(ctx, settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, u, false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
|
||||
s.audit(r, "setup", u.Username, "first administrator created")
|
||||
writeJSON(w, http.StatusCreated, s.meBody(ctx, u))
|
||||
}
|
||||
|
||||
// --- login -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
TOTPRequired bool `json:"totpRequired"`
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User, totpPending bool) error {
|
||||
token, hash, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(s.cfg.SessionIdle)
|
||||
if totpPending {
|
||||
expires = now.Add(10 * time.Minute)
|
||||
}
|
||||
ua := r.UserAgent()
|
||||
if len(ua) > 200 {
|
||||
ua = ua[:200]
|
||||
}
|
||||
if err := s.eng.Store().CreateSession(r.Context(), store.Session{
|
||||
TokenHash: hash, UserID: u.ID, CreatedAt: now, LastSeenAt: now, ExpiresAt: expires,
|
||||
IP: s.clientIP(r), UserAgent: ua, TOTPPending: totpPending,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.setCookie(w, token, now.Add(s.cfg.SessionMax))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
if ok, wait := s.ipLimit.Allowed(ip); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
||||
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
|
||||
return
|
||||
}
|
||||
var req loginRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if ok, wait := s.usrLimit.Allowed(strings.ToLower(req.Username)); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
||||
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
u, err := s.eng.Store().UserByName(ctx, req.Username)
|
||||
if err != nil {
|
||||
auth.EqualiseTiming()
|
||||
s.ipLimit.Fail(ip)
|
||||
s.usrLimit.Fail(strings.ToLower(req.Username))
|
||||
s.log.Warn("login failed", "user", req.Username, "ip", ip)
|
||||
writeError(w, http.StatusUnauthorized, "wrong username or password")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(u.PasswordHash, req.Password) {
|
||||
s.ipLimit.Fail(ip)
|
||||
s.usrLimit.Fail(strings.ToLower(req.Username))
|
||||
s.log.Warn("login failed", "user", req.Username, "ip", ip)
|
||||
_ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.failed", IP: ip})
|
||||
writeError(w, http.StatusUnauthorized, "wrong username or password")
|
||||
return
|
||||
}
|
||||
s.ipLimit.Reset(ip)
|
||||
s.usrLimit.Reset(strings.ToLower(req.Username))
|
||||
if err := s.startSession(w, r, u, u.TOTPEnabled); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if u.TOTPEnabled {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().TouchLogin(ctx, u.ID)
|
||||
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
|
||||
s.audit(r, "login", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, loginResponse{})
|
||||
}
|
||||
|
||||
type totpRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// handleLoginTOTP completes a login that is waiting on a second factor.
|
||||
// A recovery code is accepted in place of a TOTP code.
|
||||
func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
u, sess := s.loadSession(r)
|
||||
if u == nil || !sess.TOTPPending {
|
||||
writeError(w, http.StatusUnauthorized, "no login in progress")
|
||||
return
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
if ok, wait := s.ipLimit.Allowed(ip); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
||||
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
|
||||
return
|
||||
}
|
||||
var req totpRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ok := auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now())
|
||||
usedRecovery := false
|
||||
if !ok {
|
||||
used, err := s.eng.Store().UseRecoveryCode(ctx, u.ID, auth.HashToken(auth.NormaliseRecoveryCode(req.Code)))
|
||||
if err == nil && used {
|
||||
ok, usedRecovery = true, true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
s.ipLimit.Fail(ip)
|
||||
_ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.totp_failed", IP: ip})
|
||||
writeError(w, http.StatusUnauthorized, "wrong code")
|
||||
return
|
||||
}
|
||||
s.ipLimit.Reset(ip)
|
||||
if err := s.eng.Store().ClearTOTPPending(ctx, sess.TokenHash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().TouchSession(ctx, sess.TokenHash, time.Now().Add(s.cfg.SessionIdle))
|
||||
_ = s.eng.Store().TouchLogin(ctx, u.ID)
|
||||
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
|
||||
detail := ""
|
||||
if usedRecovery {
|
||||
detail = "recovery code used"
|
||||
}
|
||||
s.audit(r, "login", u.Username, detail)
|
||||
writeJSON(w, http.StatusOK, loginResponse{})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if sess := sessionOf(r); sess != nil {
|
||||
_ = s.eng.Store().DeleteSession(r.Context(), sess.TokenHash)
|
||||
}
|
||||
s.clearCookie(w)
|
||||
s.audit(r, "logout", userOf(r).Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// --- account ---------------------------------------------------------------
|
||||
|
||||
type meBody struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TOTPEnabled bool `json:"totpEnabled"`
|
||||
RecoveryCodes int `json:"recoveryCodesLeft"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt time.Time `json:"lastLoginAt,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) meBody(ctx context.Context, u *store.User) meBody {
|
||||
left, _ := s.eng.Store().RecoveryCodesLeft(ctx, u.ID)
|
||||
return meBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, RecoveryCodes: left, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.meBody(r.Context(), userOf(r)))
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
Current string `json:"current"`
|
||||
New string `json:"new"`
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req changePasswordRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u := userOf(r)
|
||||
if !auth.VerifyPassword(u.PasswordHash, req.Current) {
|
||||
writeError(w, http.StatusForbidden, "current password is wrong")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.New); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.New)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetPassword(r.Context(), u.ID, hash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Every other browser is signed out; this one keeps its session.
|
||||
sess := sessionOf(r)
|
||||
_ = s.eng.Store().DeleteUserSessions(r.Context(), u.ID)
|
||||
if err := s.startSession(w, r, u, false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = sess
|
||||
s.audit(r, "password.changed", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
type totpSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// handleTOTPSetup issues a pending secret; it becomes active on confirm.
|
||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
if u.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, "two-factor authentication is already on")
|
||||
return
|
||||
}
|
||||
secret, err := auth.NewTOTPSecret()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetTOTP(r.Context(), u.ID, secret, false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: auth.TOTPURI("WGX", u.Username, secret)})
|
||||
}
|
||||
|
||||
// handleTOTPQR renders the pending secret's otpauth URI as a QR code. Only a
|
||||
// secret that is not yet active is shown: an enabled one must never leave
|
||||
// the server again.
|
||||
func (s *Server) handleTOTPQR(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
if u.TOTPEnabled || u.TOTPSecret == "" {
|
||||
writeError(w, http.StatusNotFound, "no two-factor setup in progress")
|
||||
return
|
||||
}
|
||||
png, err := qrcode.Encode(auth.TOTPURI("WGX", u.Username, u.TOTPSecret), qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(png)
|
||||
}
|
||||
|
||||
type totpConfirmResponse struct {
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u := userOf(r)
|
||||
if u.TOTPEnabled || u.TOTPSecret == "" {
|
||||
writeError(w, http.StatusConflict, "start two-factor setup first")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now()) {
|
||||
writeError(w, http.StatusBadRequest, "wrong code; check the time on your device")
|
||||
return
|
||||
}
|
||||
codes, hashes, err := auth.NewRecoveryCodes(8)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := s.eng.Store().SetTOTP(ctx, u.ID, u.TOTPSecret, true); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, hashes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "totp.enabled", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||
}
|
||||
|
||||
type totpDisableRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpDisableRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u := userOf(r)
|
||||
if !auth.VerifyPassword(u.PasswordHash, req.Password) {
|
||||
writeError(w, http.StatusForbidden, "password is wrong")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := s.eng.Store().SetTOTP(ctx, u.ID, "", false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, nil)
|
||||
s.audit(r, "totp.disabled", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
type sessionBody struct {
|
||||
Current bool `json:"current"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
cur := sessionOf(r)
|
||||
rows, err := s.eng.Store().DB().QueryContext(r.Context(), `SELECT token_hash, created_at, last_seen_at, ip, user_agent FROM sessions WHERE user_id = ? AND totp_pending = 0 ORDER BY last_seen_at DESC`, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []sessionBody{}
|
||||
for rows.Next() {
|
||||
var hash, ip, ua string
|
||||
var created, seen int64
|
||||
if err := rows.Scan(&hash, &created, &seen, &ip, &ua); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out = append(out, sessionBody{Current: hash == cur.TokenHash, CreatedAt: time.Unix(created, 0), LastSeenAt: time.Unix(seen, 0), IP: ip, UserAgent: ua})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleRevokeSessions signs the user out everywhere but here.
|
||||
func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
cur := sessionOf(r)
|
||||
if _, err := s.eng.Store().DB().ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, u.ID, cur.TokenHash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "sessions.revoked", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// --- users -----------------------------------------------------------------
|
||||
|
||||
type userBody struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TOTPEnabled bool `json:"totpEnabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt time.Time `json:"lastLoginAt,omitempty"`
|
||||
}
|
||||
|
||||
func toUserBody(u *store.User) userBody {
|
||||
return userBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
|
||||
}
|
||||
|
||||
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.eng.Store().ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]userBody, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, toUserBody(u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type userRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func validRole(role string) bool { return role == "admin" || role == "viewer" }
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req userRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if !validUsername(req.Username) {
|
||||
writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore")
|
||||
return
|
||||
}
|
||||
if !validRole(req.Role) {
|
||||
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.eng.Store().CreateUser(r.Context(), req.Username, hash, req.Role)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
writeError(w, http.StatusConflict, "that username is taken")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "user.created", u.Username, "role "+u.Role)
|
||||
writeJSON(w, http.StatusCreated, toUserBody(u))
|
||||
}
|
||||
|
||||
type userUpdateRequest struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ResetTOTP bool `json:"resetTotp,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad user id")
|
||||
return
|
||||
}
|
||||
var req userUpdateRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
target, err := s.eng.Store().UserByID(ctx, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "no such user")
|
||||
return
|
||||
}
|
||||
var changes []string
|
||||
if req.Role != "" && req.Role != target.Role {
|
||||
if !validRole(req.Role) {
|
||||
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
|
||||
return
|
||||
}
|
||||
if target.ID == userOf(r).ID {
|
||||
writeError(w, http.StatusBadRequest, "you cannot change your own role")
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetRole(ctx, id, req.Role); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
changes = append(changes, "role "+req.Role)
|
||||
}
|
||||
if req.Password != "" {
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetPassword(ctx, id, hash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().DeleteUserSessions(ctx, id)
|
||||
changes = append(changes, "password reset")
|
||||
}
|
||||
if req.ResetTOTP && target.TOTPEnabled {
|
||||
if err := s.eng.Store().SetTOTP(ctx, id, "", false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().ReplaceRecoveryCodes(ctx, id, nil)
|
||||
changes = append(changes, "two-factor reset")
|
||||
}
|
||||
if len(changes) > 0 {
|
||||
s.audit(r, "user.updated", target.Username, strings.Join(changes, ", "))
|
||||
}
|
||||
u, _ := s.eng.Store().UserByID(ctx, id)
|
||||
writeJSON(w, http.StatusOK, toUserBody(u))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad user id")
|
||||
return
|
||||
}
|
||||
if id == userOf(r).ID {
|
||||
writeError(w, http.StatusBadRequest, "you cannot delete yourself")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
target, err := s.eng.Store().UserByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such user")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().DeleteUser(ctx, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "user.deleted", target.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
Reference in New Issue
Block a user