3 Commits

Author SHA1 Message Date
fabe5319ae Added UI for mfa and other profile settings 2026-06-10 01:00:45 +02:00
e2926df62c updated dependencies 2026-06-10 00:38:16 +02:00
ea8ea45c4c started with 2fa support 2026-06-09 22:50:29 +02:00
14 changed files with 1322 additions and 155 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ appdata
*.exe *.exe
*.cmd *.cmd
.run .run
*.out

View File

@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"errors"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -12,6 +13,12 @@ type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type PurposeClaims struct {
UserID string `json:"user_id"`
Purpose string `json:"purpose"`
jwt.RegisteredClaims
}
func GenerateJWT(userID, role string, secret []byte) (string, error) { func GenerateJWT(userID, role string, secret []byte) (string, error) {
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
@@ -25,8 +32,26 @@ func GenerateJWT(userID, role string, secret []byte) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret) return token.SignedString(secret)
} }
func GeneratePurposeJWT(userID, purpose string, secret []byte, ttl time.Duration) (string, error) {
claims := PurposeClaims{
UserID: userID,
Purpose: purpose,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) { func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return secret, nil return secret, nil
}) })
if err != nil { if err != nil {
@@ -35,7 +60,29 @@ func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
claims, ok := token.Claims.(*Claims) claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid { if !ok || !token.Valid {
return nil, err return nil, errors.New("invalid token")
}
return claims, nil
}
func ValidatePurposeJWT(tokenStr, expectedPurpose string, secret []byte) (*PurposeClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &PurposeClaims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*PurposeClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
if claims.Purpose != expectedPurpose {
return nil, errors.New("invalid token purpose")
} }
return claims, nil return claims, nil

View File

@@ -1,5 +0,0 @@
sudo docker buildx build \
--platform linux/amd64,linux/arm64 \
-t git.miaurizius.de/miaurizius/miauinv:latest \
-t git.miaurizius.de/miaurizius/miauinv:v1.0.2 \
--push .

View File

@@ -39,6 +39,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById('items-table-body')) loadItems(); if (document.getElementById('items-table-body')) loadItems();
if (document.getElementById('locations-table-body')) loadLocations(); if (document.getElementById('locations-table-body')) loadLocations();
if (document.getElementById('projects-table-body')) loadProjects(); if (document.getElementById('projects-table-body')) loadProjects();
if (document.getElementById('account-settings-content')) loadAccountSettings();
loadProfile(); loadProfile();
}); });
@@ -482,3 +483,246 @@ async function loadProfile() {
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>'; username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
} }
} }
// ---- ACCOUNT SETTINGS ----
let latestRecoveryCodes = [];
function showAccountSettingsMessage(message, type = 'success') {
const box = document.getElementById('account-settings-message');
if (!box) return;
box.textContent = message;
box.className = `message ${type}`;
box.style.display = 'block';
}
function setTwoFactorPanels(enabled) {
const badge = document.getElementById('two-factor-badge');
const status = document.getElementById('two-factor-status');
const disabledPanel = document.getElementById('two-factor-disabled-panel');
const enabledPanel = document.getElementById('two-factor-enabled-panel');
if (!badge || !status || !disabledPanel || !enabledPanel) return;
if (enabled) {
badge.textContent = 'Enabled';
badge.classList.add('success');
status.textContent = '2FA is enabled for your account.';
disabledPanel.style.display = 'none';
enabledPanel.style.display = 'block';
} else {
badge.textContent = 'Disabled';
badge.classList.remove('success');
status.textContent = '2FA is disabled. Enable it to protect your account with an authenticator app.';
disabledPanel.style.display = 'block';
enabledPanel.style.display = 'none';
}
}
function renderRecoveryCodes(codes) {
latestRecoveryCodes = codes || [];
const panel = document.getElementById('recovery-codes-panel');
const list = document.getElementById('recovery-codes-list');
if (!panel || !list) return;
if (latestRecoveryCodes.length === 0) {
panel.style.display = 'none';
list.textContent = '';
return;
}
list.textContent = latestRecoveryCodes.join('\n');
panel.style.display = 'block';
}
async function loadAccountSettings() {
try {
const data = await apiRequest('/api/profile');
const usernameInput = document.getElementById('settings-username');
const avatarPreview = document.getElementById('settings-avatar-preview');
const remaining = document.getElementById('recovery-codes-remaining');
if (usernameInput) usernameInput.value = data.username || '';
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
setTwoFactorPanels(!!data.two_factor_enabled);
renderRecoveryCodes([]);
} catch (err) {
showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error');
}
}
async function saveAccountUsername(event) {
event.preventDefault();
try {
const data = await apiRequest('/api/account/username', 'POST', {
username: document.getElementById('settings-username').value.trim(),
password: document.getElementById('settings-username-password').value
});
document.getElementById('settings-username-password').value = '';
showAccountSettingsMessage('Username updated.');
const username = document.getElementById('username');
const avatar = document.getElementById('avatar');
const avatarPreview = document.getElementById('settings-avatar-preview');
if (username) username.innerText = data.username;
if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase();
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not update username.', 'error');
}
}
async function saveAccountPassword(event) {
event.preventDefault();
const currentPassword = document.getElementById('settings-current-password').value;
const newPassword = document.getElementById('settings-new-password').value;
const confirmPassword = document.getElementById('settings-confirm-password').value;
if (newPassword !== confirmPassword) {
showAccountSettingsMessage('New passwords do not match.', 'error');
return;
}
try {
const data = await apiRequest('/api/account/password', 'POST', {
current_password: currentPassword,
new_password: newPassword
});
if (data && data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
document.getElementById('password-form').reset();
showAccountSettingsMessage('Password updated. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not update password.', 'error');
}
}
async function startTwoFactorSetup() {
try {
const data = await apiRequest('/api/2fa/setup', 'POST');
const panel = document.getElementById('two-factor-setup-panel');
const qr = document.getElementById('two-factor-qr');
const secret = document.getElementById('two-factor-secret');
const otpauth = document.getElementById('two-factor-otpauth');
if (panel) panel.style.display = 'block';
if (qr) {
qr.src = data.qr_code;
qr.style.display = data.qr_code ? 'block' : 'none';
}
if (secret) secret.textContent = data.secret || '';
if (otpauth) {
otpauth.href = data.otpauth_url || '#';
otpauth.textContent = data.otpauth_url || 'No otpauth URL available';
}
showAccountSettingsMessage('Scan the QR code or enter the setup key manually, then confirm the 6-digit code.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not start 2FA setup.', 'error');
}
}
async function enableTwoFactor(event) {
event.preventDefault();
try {
const data = await apiRequest('/api/2fa/enable', 'POST', {
code: document.getElementById('two-factor-enable-code').value.trim()
});
document.getElementById('two-factor-enable-form').reset();
document.getElementById('two-factor-setup-panel').style.display = 'none';
setTwoFactorPanels(true);
renderRecoveryCodes(data.recovery_codes || []);
const remaining = document.getElementById('recovery-codes-remaining');
if (remaining) remaining.innerText = (data.recovery_codes || []).length;
showAccountSettingsMessage('2FA enabled. Download your recovery codes now.');
loadProfile();
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not enable 2FA.', 'error');
}
}
async function disableTwoFactor(event) {
event.preventDefault();
if (!confirm('Disable 2FA for your account?')) return;
try {
await apiRequest('/api/2fa/disable', 'POST', {
password: document.getElementById('two-factor-disable-password').value,
code: document.getElementById('two-factor-disable-code').value.trim()
});
document.getElementById('two-factor-disable-form').reset();
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setTwoFactorPanels(false);
renderRecoveryCodes([]);
showAccountSettingsMessage('2FA disabled. Redirecting to login because sessions were revoked.');
setTimeout(() => {
window.location.href = '/login';
}, 1200);
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not disable 2FA.', 'error');
}
}
async function regenerateRecoveryCodes(event) {
event.preventDefault();
if (!confirm('Generate new recovery codes? Existing unused codes will stop working.')) return;
try {
const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', {
password: document.getElementById('recovery-password').value,
code: document.getElementById('recovery-code').value.trim()
});
document.getElementById('recovery-regenerate-form').reset();
renderRecoveryCodes(data.recovery_codes || []);
const remaining = document.getElementById('recovery-codes-remaining');
if (remaining) remaining.innerText = (data.recovery_codes || []).length;
showAccountSettingsMessage('New recovery codes generated. Download them now.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not regenerate recovery codes.', 'error');
}
}
function downloadRecoveryCodes() {
if (!latestRecoveryCodes || latestRecoveryCodes.length === 0) {
showAccountSettingsMessage('No recovery codes available to download.', 'error');
return;
}
const text = [
'MiauInv recovery codes',
'Save these somewhere safe. Each code can be used once.',
'',
...latestRecoveryCodes,
''
].join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'miauinv-recovery-codes.txt';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}

View File

@@ -2,22 +2,63 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("login-form"); const form = document.getElementById("login-form");
const errorBox = document.getElementById("error"); const errorBox = document.getElementById("error");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const twoFactorInput = document.getElementById("two-factor-code");
const twoFactorGroup = document.getElementById("two-factor-group");
const submitButton = document.getElementById("login-submit");
let pendingTwoFactorToken = null;
if (!form) return; if (!form) return;
function showError(message) {
errorBox.textContent = message || "Login failed.";
errorBox.style.display = "block";
}
function storeTokens(data) {
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
}
function switchToTwoFactorMode(token) {
pendingTwoFactorToken = token;
usernameInput.disabled = true;
passwordInput.disabled = true;
twoFactorGroup.style.display = "block";
twoFactorInput.required = true;
twoFactorInput.focus();
submitButton.textContent = "Verify code";
}
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
errorBox.style.display = "none"; errorBox.style.display = "none";
submitButton.disabled = true;
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
try { try {
const response = await fetch("/api/login", { let response;
if (pendingTwoFactorToken) {
response = await fetch("/api/login/2fa", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }) body: JSON.stringify({
two_factor_token: pendingTwoFactorToken,
code: twoFactorInput.value.trim()
})
}); });
} else {
response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: usernameInput.value,
password: passwordInput.value
})
});
}
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
@@ -26,17 +67,17 @@ document.addEventListener("DOMContentLoaded", () => {
const data = await response.json(); const data = await response.json();
localStorage.setItem("access_token", data.access_token); if (data.requires_2fa) {
localStorage.setItem("refresh_token", data.refresh_token); switchToTwoFactorMode(data.two_factor_token);
return;
document.cookie = `access_token=${data.access_token}; path=/; max-age=900; SameSite=Lax; Secure`; }
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=604800; SameSite=Lax; Secure`;
storeTokens(data);
window.location.href = "/dashboard"; window.location.href = "/dashboard";
} catch (err) { } catch (err) {
errorBox.textContent = err.message || "Login failed."; showError(err.message);
errorBox.style.display = "block"; } finally {
submitButton.disabled = false;
} }
}); });
}); });

View File

@@ -33,6 +33,10 @@ var projects = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html", "frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/projects.html")) "frontend/htmx/contents/dash/projects.html"))
var accountSettings = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/account_settings.html"))
var home = template.Must(template.ParseFiles("frontend/htmx/home.html")) var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
func Home(w http.ResponseWriter, r *http.Request) { func Home(w http.ResponseWriter, r *http.Request) {
@@ -51,7 +55,6 @@ func Home(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
func Dashboard(w http.ResponseWriter, r *http.Request) { func Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
@@ -142,6 +145,17 @@ func Projects(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
func AccountSettings(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := accountSettings.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Account Settings",
})
if err != nil {
return
}
}
var minifier *minify.M var minifier *minify.M
@@ -151,7 +165,6 @@ func init() {
minifier.AddFunc("text/css", css.Minify) minifier.AddFunc("text/css", css.Minify)
minifier.AddFunc("text/javascript", js.Minify) minifier.AddFunc("text/javascript", js.Minify)
} }
func Assets(w http.ResponseWriter, r *http.Request) { func Assets(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/assets/") path := strings.TrimPrefix(r.URL.Path, "/assets/")
fullPath := filepath.Join("frontend/assets", path) fullPath := filepath.Join("frontend/assets", path)

View File

@@ -0,0 +1,139 @@
{{ define "content" }}
<div class="page-header">
<h1>Account Settings</h1>
</div>
<div id="account-settings-content">
<div id="account-settings-message" class="message" style="display: none; margin-bottom: 1.5rem;"></div>
<div class="modal-split" style="align-items: start;">
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Profile</h2>
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your username. Avatar upload is planned for later.</p>
<form id="username-form" onsubmit="saveAccountUsername(event)">
<div class="form-group">
<label for="settings-username" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Username</label>
<input type="text" id="settings-username" placeholder="Username" required>
</div>
<div class="form-group">
<label for="settings-username-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Current password</label>
<input type="password" id="settings-username-password" placeholder="Confirm with current password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary">Save username</button>
</form>
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Avatar</h3>
<div style="display:flex; align-items:center; gap:1rem; color: var(--text-muted);">
<div id="settings-avatar-preview" class="avatar">M</div>
<div>
<div>Avatar upload is not implemented yet.</div>
<div style="font-size:0.85rem; margin-top:0.25rem;">This placeholder keeps the settings layout ready for it.</div>
</div>
</div>
</div>
</div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Password</h2>
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your password. You will receive a fresh session afterwards.</p>
<form id="password-form" onsubmit="saveAccountPassword(event)">
<div class="form-group">
<label for="settings-current-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Current password</label>
<input type="password" id="settings-current-password" placeholder="Current password" required autocomplete="current-password">
</div>
<div class="form-group">
<label for="settings-new-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">New password</label>
<input type="password" id="settings-new-password" placeholder="New password" required autocomplete="new-password">
</div>
<div class="form-group">
<label for="settings-confirm-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Confirm new password</label>
<input type="password" id="settings-confirm-password" placeholder="Confirm new password" required autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">Change password</button>
</form>
</div>
</div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem; margin-top: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;">
<div>
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Two-factor authentication</h2>
<p id="two-factor-status" style="color: var(--text-muted); margin-bottom: 1rem;">Loading 2FA status...</p>
</div>
<span id="two-factor-badge" class="badge">Unknown</span>
</div>
<div id="two-factor-disabled-panel" style="display: none;">
<p style="color: var(--text-muted); margin-bottom: 1rem;">Use an authenticator app. You can scan the QR code or enter the setup key manually.</p>
<button type="button" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;" onclick="startTwoFactorSetup()">Start 2FA setup</button>
<div id="two-factor-setup-panel" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
<div class="modal-split" style="align-items: start;">
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Scan QR code</h3>
<img id="two-factor-qr" alt="2FA QR code" style="display: none; width: 220px; height: 220px; background: white; padding: 0.5rem; border-radius: 12px;">
</div>
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Manual setup</h3>
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">If you do not want to scan the QR code, enter this key manually in your authenticator app.</p>
<code id="two-factor-secret" style="display:block; word-break: break-all; background:#111827; border:1px solid var(--border); border-radius:10px; padding:0.85rem; color:var(--text);"></code>
<a id="two-factor-otpauth" href="#" style="display:block; color: var(--accent); margin-top:0.75rem; word-break: break-all;">Open otpauth URL</a>
</div>
</div>
<form id="two-factor-enable-form" onsubmit="enableTwoFactor(event)" style="margin-top: 1.5rem;">
<div class="form-group">
<label for="two-factor-enable-code" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Authenticator code</label>
<input type="text" id="two-factor-enable-code" inputmode="numeric" placeholder="123456" required autocomplete="one-time-code">
</div>
<button type="submit" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;">Enable 2FA</button>
</form>
</div>
</div>
<div id="two-factor-enabled-panel" style="display: none;">
<p style="color: var(--text-muted); margin-bottom: 1rem;">Recovery codes remaining: <strong id="recovery-codes-remaining">0</strong></p>
<div id="recovery-codes-panel" style="display: none; margin-bottom: 1.5rem; padding: 1rem; border: 1px solid var(--border); border-radius: 12px; background: #111827;">
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Recovery codes</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">Save these now. They are shown only once.</p>
<pre id="recovery-codes-list" style="white-space: pre-wrap; word-break: break-word; color: var(--text); background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; margin-bottom: 1rem;"></pre>
<button type="button" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;" onclick="downloadRecoveryCodes()">Download recovery codes</button>
</div>
<div class="modal-split" style="align-items: start;">
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Regenerate recovery codes</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">This invalidates all existing recovery codes.</p>
<form id="recovery-regenerate-form" onsubmit="regenerateRecoveryCodes(event)">
<div class="form-group">
<input type="password" id="recovery-password" placeholder="Current password" required autocomplete="current-password">
</div>
<div class="form-group">
<input type="text" id="recovery-code" inputmode="numeric" placeholder="Authenticator code" required autocomplete="one-time-code">
</div>
<button type="submit" class="btn btn-secondary">Generate new recovery codes</button>
</form>
</div>
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Disable 2FA</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">Disabling 2FA revokes your active refresh sessions.</p>
<form id="two-factor-disable-form" onsubmit="disableTwoFactor(event)">
<div class="form-group">
<input type="password" id="two-factor-disable-password" placeholder="Current password" required autocomplete="current-password">
</div>
<div class="form-group">
<input type="text" id="two-factor-disable-code" inputmode="numeric" placeholder="Authenticator code" required autocomplete="one-time-code">
</div>
<button type="submit" class="btn btn-secondary danger-btn">Disable 2FA</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -24,12 +24,18 @@
<input type="text" id="username" placeholder="Username" autocomplete="username" required> <input type="text" id="username" placeholder="Username" autocomplete="username" required>
</div> </div>
<div class="form-group"> <div class="form-group" id="password-group">
<label for="password" class="sr-only">Password</label> <label for="password" class="sr-only">Password</label>
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required> <input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
</div> </div>
<button type="submit" class="btn btn-primary">Sign In</button> <div class="form-group" id="two-factor-group" style="display: none;">
<label for="two-factor-code" class="sr-only">2FA code</label>
<input type="text" id="two-factor-code" placeholder="Authenticator or recovery code" autocomplete="one-time-code" inputmode="text" pattern="[0-9A-Za-z\- ]*">
<p class="subtitle" style="margin-top: 0.75rem;">Enter your 6-digit authenticator code or one recovery code.</p>
</div>
<button type="submit" id="login-submit" class="btn btn-primary">Sign In</button>
</form> </form>
<div id="error" class="message error"></div> <div id="error" class="message error"></div>

20
go.mod
View File

@@ -5,20 +5,22 @@ go 1.26
require ( require (
github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.5.0 github.com/google/uuid v1.6.0
github.com/pquerna/otp v1.5.0
github.com/tdewolff/minify/v2 v2.24.13 github.com/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.53.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/boombuler/barcode v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect github.com/tdewolff/parse/v2 v2.8.13 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.37.6 // indirect modernc.org/libc v1.73.0 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.28.0 // indirect modernc.org/sqlite v1.52.0 // indirect
) )

34
go.sum
View File

@@ -1,3 +1,9 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -6,33 +12,61 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58= github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0= github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg= github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/libc v1.73.0 h1:Y/KmTxbIN5T3x+NFjYOzV/+Ha7wKClfIecmTCTuYlqQ=
modernc.org/libc v1.73.0/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

View File

@@ -2,19 +2,23 @@ package handlers
import ( import (
"MiauInv/auth" "MiauInv/auth"
"MiauInv/config"
"MiauInv/models" "MiauInv/models"
"MiauInv/storage" "MiauInv/storage"
"MiauInv/util" utils "MiauInv/util"
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"image/png"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time" "time"
)
var cfg, _ = config.LoadConfig() "github.com/pquerna/otp/totp"
)
func APIRegister(w http.ResponseWriter, r *http.Request) { func APIRegister(w http.ResponseWriter, r *http.Request) {
var user models.User var user models.User
@@ -86,76 +90,407 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
return return
} }
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret) if user.TwoFactorEnabled {
twoFactorToken, err := auth.GeneratePurposeJWT(user.ID, "2fa_login", secret, 5*time.Minute)
if err != nil { if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate token", http.StatusInternalServerError) http.Error(w, "Could not generate 2FA challenge", http.StatusInternalServerError)
return return
} }
refreshTokenPlain, err := utils.GenerateRefreshToken() writeJSON(w, http.StatusOK, map[string]interface{}{
if err != nil { "requires_2fa": true,
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) "two_factor_token": twoFactorToken,
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
return
}
refreshHash := utils.HashToken(refreshTokenPlain)
refreshID := utils.GenerateUUID()
refreshExpires := time.Now().Add(7 * 24 * time.Hour).Unix() // expiry: 7 days
deviceInfo := r.Header.Get("User-Agent")
if err := storage.AddRefreshToken(&models.RefreshToken{
ID: refreshID,
UserID: user.ID,
Token: refreshHash,
ExpiresAt: refreshExpires,
DeviceInfo: deviceInfo,
CreatedAt: time.Now().Unix(),
Revoked: false,
}); err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not save refresh token", http.StatusInternalServerError)
return
}
// Return access + refresh token (refresh in plain for client to store securely)
resp := map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshTokenPlain,
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"role": user.Role,
},
}
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: accessToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
}) })
http.SetCookie(w, &http.Cookie{ log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
Name: "refresh_token",
Value: refreshTokenPlain,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(resp)
if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return return
} }
issueLoginSession(w, r, user)
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in") log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
} }
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
var req struct {
TwoFactorToken string `json:"two_factor_token"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
secret := []byte(os.Getenv("JWT_SECRET"))
claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, "2fa_login", secret)
if err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized)
return
}
user, err := storage.GetUserById(claims.UserID)
if err != nil || !user.TwoFactorEnabled || user.TwoFactorSecret == "" {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": 2FA not available for user")
http.Error(w, "Invalid 2FA state", http.StatusUnauthorized)
return
}
code := strings.TrimSpace(req.Code)
validTOTP := totp.Validate(code, user.TwoFactorSecret)
usedRecoveryCode := false
if !validTOTP {
recoveryCodeHash := utils.HashToken(normalizeRecoveryCode(code))
usedRecoveryCode, err = storage.UseUserRecoveryCode(user.ID, recoveryCodeHash)
if err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not validate recovery code", http.StatusInternalServerError)
return
}
}
if !validTOTP && !usedRecoveryCode {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Invalid 2FA or recovery code")
http.Error(w, "Invalid 2FA or recovery code", http.StatusUnauthorized)
return
}
issueLoginSession(w, r, user)
if usedRecoveryCode {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with recovery code")
return
}
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
}
func AccountUpdateUsername(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(req.Username)
if username == "" || req.Password == "" {
http.Error(w, "Username and password required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if err := storage.UpdateUserUsername(user.ID, username); err != nil {
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Username already exists or could not be saved", http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"username": strings.ToLower(username),
})
log.Println("POST [api/account/username] " + r.RemoteAddr + ": Updated username")
}
func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "Current and new password required", http.StatusBadRequest)
return
}
if len(req.NewPassword) > 72 {
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
return
}
if req.CurrentPassword == req.NewPassword {
http.Error(w, "New password must be different", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.CurrentPassword, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
hashed, err := auth.HashPassword(req.NewPassword)
if err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not hash password", http.StatusInternalServerError)
return
}
if err := storage.UpdateUserPassword(user.ID, hashed); err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not update password", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
user.Password = hashed
issueLoginSession(w, r, user)
log.Println("POST [api/account/password] " + r.RemoteAddr + ": Updated password")
}
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if user.TwoFactorEnabled {
http.Error(w, "2FA is already enabled", http.StatusConflict)
return
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "MiauInv",
AccountName: user.Username,
SecretSize: 20,
})
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate 2FA secret", http.StatusInternalServerError)
return
}
if err := storage.SetUserTwoFactorSecret(user.ID, key.Secret()); err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save 2FA secret", http.StatusInternalServerError)
return
}
img, err := key.Image(220, 220)
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate QR code", http.StatusInternalServerError)
return
}
var qr bytes.Buffer
if err := png.Encode(&qr, img); err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not encode QR code", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"secret": key.Secret(),
"otpauth_url": key.URL(),
"qr_code": "data:image/png;base64," + base64.StdEncoding.EncodeToString(qr.Bytes()),
})
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
}
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if user.TwoFactorSecret == "" {
http.Error(w, "2FA setup has not been started", http.StatusBadRequest)
return
}
if !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
recoveryCodes, recoveryCodeHashes, err := generateRecoveryCodes(10)
if err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate recovery codes", http.StatusInternalServerError)
return
}
if err := storage.EnableUserTwoFactorWithRecoveryCodes(user.ID, recoveryCodeHashes); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not enable 2FA", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"two_factor_enabled": true,
"recovery_codes": recoveryCodes,
})
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA and generated recovery codes")
}
func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if user.TwoFactorEnabled && !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
if err := storage.DisableUserTwoFactor(user.ID); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not disable 2FA", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke sessions", http.StatusInternalServerError)
return
}
clearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false})
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
}
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !user.TwoFactorEnabled || user.TwoFactorSecret == "" {
http.Error(w, "2FA is not enabled", http.StatusBadRequest)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
recoveryCodes, recoveryCodeHashes, err := generateRecoveryCodes(10)
if err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate recovery codes", http.StatusInternalServerError)
return
}
if err := storage.ReplaceUserRecoveryCodes(user.ID, recoveryCodeHashes); err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save recovery codes", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"recovery_codes": recoveryCodes,
})
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": Regenerated recovery codes")
}
func Logout(w http.ResponseWriter, r *http.Request) { func Logout(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims) claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
err := storage.RevokeAllRefreshTokensForUser(claims.UserID) err := storage.RevokeAllRefreshTokensForUser(claims.UserID)
@@ -164,7 +499,8 @@ func Logout(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
w.WriteHeader(204) clearAuthCookies(w)
w.WriteHeader(http.StatusNoContent)
} }
func TestHandler(w http.ResponseWriter, r *http.Request) { func TestHandler(w http.ResponseWriter, r *http.Request) {
claims, _ := utils.IsLoggedIn(w, r) claims, _ := utils.IsLoggedIn(w, r)
@@ -185,9 +521,19 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) if r.Body != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) _ = json.NewDecoder(r.Body).Decode(&req)
}
if req.RefreshToken == "" {
cookie, err := r.Cookie("refresh_token")
if err == nil {
req.RefreshToken = cookie.Value
}
}
if req.RefreshToken == "" {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Missing refresh token")
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return return
} }
@@ -204,41 +550,14 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
log.Println(err) log.Println(err)
} }
newToken, _ := utils.GenerateRefreshToken()
newHash := utils.HashToken(newToken)
newExpires := time.Now().Add(7 * 24 * time.Hour).Unix() //7 days
newID := utils.GenerateUUID()
deviceInfo := r.Header.Get("User-Agent")
if err = storage.AddRefreshToken(&models.RefreshToken{
ID: newID,
UserID: tokenRow.UserID,
Token: newHash,
ExpiresAt: newExpires,
CreatedAt: time.Now().Unix(),
Revoked: false,
DeviceInfo: deviceInfo,
}); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate new refresh token", http.StatusInternalServerError)
return
}
user, err := storage.GetUserById(tokenRow.UserID) user, err := storage.GetUserById(tokenRow.UserID)
if err != nil { if err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
accessToken, _ := auth.GenerateJWT(tokenRow.UserID, user.Role, []byte(os.Getenv("JWT_SECRET")))
if err = json.NewEncoder(w).Encode(map[string]string{ issueLoginSession(w, r, user)
"access_token": accessToken,
"refresh_token": newToken,
}); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token") log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
} }
func UserInfo(w http.ResponseWriter, r *http.Request) { func UserInfo(w http.ResponseWriter, r *http.Request) {
@@ -287,11 +606,21 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "User not found", http.StatusNotFound) http.Error(w, "User not found", http.StatusNotFound)
return return
} }
recoveryCodesRemaining := 0
if user.TwoFactorEnabled {
if count, err := storage.CountUnusedRecoveryCodes(user.ID); err == nil {
recoveryCodesRemaining = count
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{ err = json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID, "id": user.ID,
"username": user.Username, "username": user.Username,
"avatar_url": "", "avatar_url": "",
"two_factor_enabled": user.TwoFactorEnabled,
"recovery_codes_remaining": recoveryCodesRemaining,
}) })
if err != nil { if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error()) log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
@@ -299,3 +628,133 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
} }
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")") log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
} }
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) == 0 {
log.Println("AUTH " + r.RemoteAddr + ": Server misconfiguration")
http.Error(w, "Server misconfiguration", http.StatusInternalServerError)
return
}
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret)
if err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate token", http.StatusInternalServerError)
return
}
refreshTokenPlain, err := utils.GenerateRefreshToken()
if err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
return
}
refreshExpires := time.Now().Add(7 * 24 * time.Hour).Unix()
if err := storage.AddRefreshToken(&models.RefreshToken{
ID: utils.GenerateUUID(),
UserID: user.ID,
Token: utils.HashToken(refreshTokenPlain),
ExpiresAt: refreshExpires,
DeviceInfo: r.Header.Get("User-Agent"),
CreatedAt: time.Now().Unix(),
Revoked: false,
}); err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not save refresh token", http.StatusInternalServerError)
return
}
setAuthCookies(w, accessToken, refreshTokenPlain)
writeJSON(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshTokenPlain,
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"role": user.Role,
"two_factor_enabled": user.TwoFactorEnabled,
},
})
}
func generateRecoveryCodes(count int) ([]string, []string, error) {
codes := make([]string, 0, count)
hashes := make([]string, 0, count)
seen := make(map[string]struct{}, count)
for len(codes) < count {
code, err := generateRecoveryCode()
if err != nil {
return nil, nil, err
}
normalized := normalizeRecoveryCode(code)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
codes = append(codes, code)
hashes = append(hashes, utils.HashToken(normalized))
}
return codes, hashes, nil
}
func generateRecoveryCode() (string, error) {
bytes := make([]byte, 10)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
raw := hex.EncodeToString(bytes)
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
}
func normalizeRecoveryCode(code string) string {
code = strings.TrimSpace(code)
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
return strings.ToLower(code)
}
func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: accessToken,
Path: "/",
MaxAge: 15 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshToken,
Path: "/",
MaxAge: 7 * 24 * 60 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{"access_token", "refresh_token"} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Println("JSON response error: " + err.Error())
}
}

View File

@@ -5,4 +5,6 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Role string `json:"role"` Role string `json:"role"`
TwoFactorEnabled bool `json:"two_factor_enabled"`
TwoFactorSecret string `json:"-"`
} }

View File

@@ -71,6 +71,7 @@ func (this *Server) Run() {
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items))) mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations))) mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects))) mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
mux.Handle("/profile/settings", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.AccountSettings)))
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html")) mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
if this.AllowRegistration { if this.AllowRegistration {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html")) mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
@@ -82,10 +83,17 @@ func (this *Server) Run() {
// API // API
// //
mux.HandleFunc("/api/login", handlers.APILogin) mux.HandleFunc("/api/login", handlers.APILogin)
mux.HandleFunc("/api/login/2fa", handlers.APILoginTwoFactor)
mux.HandleFunc("/api/refresh", handlers.RefreshToken) mux.HandleFunc("/api/refresh", handlers.RefreshToken)
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout))) mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout)))
mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo))) mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.HandleFunc("/api/userinfo", handlers.UserInfo) mux.Handle("/api/2fa/setup", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup)))
mux.Handle("/api/2fa/enable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable)))
mux.Handle("/api/2fa/disable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable)))
mux.Handle("/api/2fa/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes)))
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.Handle("/api/account/username", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername)))
mux.Handle("/api/account/password", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword)))
if this.AllowRegistration { if this.AllowRegistration {
mux.HandleFunc("/api/register", handlers.APIRegister) mux.HandleFunc("/api/register", handlers.APIRegister)
} }

View File

@@ -2,10 +2,12 @@ package storage
import ( import (
"MiauInv/models" "MiauInv/models"
utils "MiauInv/util"
"database/sql" "database/sql"
"errors" "errors"
"log" "log"
"strings" "strings"
"time"
_ "github.com/glebarez/go-sqlite" _ "github.com/glebarez/go-sqlite"
) )
@@ -27,7 +29,9 @@ func InitDB(filepath string) error {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, password TEXT NOT NULL,
role TEXT NOT NULL role TEXT NOT NULL,
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
two_factor_secret TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE TABLE IF NOT EXISTS refresh_tokens (
@@ -41,6 +45,16 @@ func InitDB(filepath string) error {
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
); );
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
code_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
used_at INTEGER DEFAULT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, code_hash)
);
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -84,27 +98,189 @@ func InitDB(filepath string) error {
log.Fatal(err) log.Fatal(err)
} }
if err := ensureUserTwoFactorColumns(); err != nil {
return err return err
} }
return nil
}
func ensureUserTwoFactorColumns() error {
migrations := []string{
"ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN two_factor_secret TEXT NOT NULL DEFAULT ''",
}
for _, migration := range migrations {
_, err := DB.Exec(migration)
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
}
return nil
}
// Users // Users
func AddUser(user *models.User) error { func AddUser(user *models.User) error {
_, err := DB.Exec("INSERT INTO users(id, username, password, role) VALUES (?, ?, ?, ?)", user.ID, strings.ToLower(user.Username), user.Password, user.Role) _, err := DB.Exec("INSERT INTO users(id, username, password, role) VALUES (?, ?, ?, ?)", user.ID, strings.ToLower(user.Username), user.Password, user.Role)
return err return err
} }
func GetUserByUsername(username string) (models.User, error) { func GetUserByUsername(username string) (models.User, error) {
row := DB.QueryRow("SELECT * FROM users WHERE username = ?", strings.ToLower(username)) row := DB.QueryRow(`
var user models.User SELECT id, username, password, role, two_factor_enabled, two_factor_secret
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role) FROM users
return user, err WHERE username = ?
`, strings.ToLower(username))
return scanUser(row)
} }
func GetUserById(id string) (models.User, error) { func GetUserById(id string) (models.User, error) {
row := DB.QueryRow("SELECT * FROM users WHERE id = ?", id) row := DB.QueryRow(`
SELECT id, username, password, role, two_factor_enabled, two_factor_secret
FROM users
WHERE id = ?
`, id)
return scanUser(row)
}
func scanUser(row *sql.Row) (models.User, error) {
var user models.User var user models.User
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role) var twoFactorEnabled int
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role, &twoFactorEnabled, &user.TwoFactorSecret)
user.TwoFactorEnabled = twoFactorEnabled == 1
return user, err return user, err
} }
func UpdateUserUsername(userID, username string) error {
res, err := DB.Exec("UPDATE users SET username = ? WHERE id = ?", strings.ToLower(username), userID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
func UpdateUserPassword(userID, passwordHash string) error {
res, err := DB.Exec("UPDATE users SET password = ? WHERE id = ?", passwordHash, userID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
func SetUserTwoFactorSecret(userID, secret string) error {
_, err := DB.Exec("UPDATE users SET two_factor_secret = ? WHERE id = ?", secret, userID)
return err
}
func EnableUserTwoFactorWithRecoveryCodes(userID string, recoveryCodeHashes []string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
now := time.Now().Unix()
for _, codeHash := range recoveryCodeHashes {
if _, err := tx.Exec(`
INSERT INTO two_factor_recovery_codes(id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)
`, utils.GenerateUUID(), userID, codeHash, now); err != nil {
return err
}
}
if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 1 WHERE id = ?", userID); err != nil {
return err
}
return tx.Commit()
}
func DisableUserTwoFactor(userID string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 0, two_factor_secret = '' WHERE id = ?", userID); err != nil {
return err
}
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
return tx.Commit()
}
func ReplaceUserRecoveryCodes(userID string, recoveryCodeHashes []string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
now := time.Now().Unix()
for _, codeHash := range recoveryCodeHashes {
if _, err := tx.Exec(`
INSERT INTO two_factor_recovery_codes(id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)
`, utils.GenerateUUID(), userID, codeHash, now); err != nil {
return err
}
}
return tx.Commit()
}
func UseUserRecoveryCode(userID, codeHash string) (bool, error) {
res, err := DB.Exec(`
UPDATE two_factor_recovery_codes
SET used_at = ?
WHERE user_id = ? AND code_hash = ? AND used_at IS NULL
`, time.Now().Unix(), userID, codeHash)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n == 1, nil
}
func CountUnusedRecoveryCodes(userID string) (int, error) {
var count int
err := DB.QueryRow(`
SELECT COUNT(*)
FROM two_factor_recovery_codes
WHERE user_id = ? AND used_at IS NULL
`, userID).Scan(&count)
return count, err
}
// Refresh Tokens // Refresh Tokens
func AddRefreshToken(token *models.RefreshToken) error { func AddRefreshToken(token *models.RefreshToken) error {
_, err := DB.Exec("INSERT INTO refresh_tokens(id, user_id, token_hash, expires_at, created_at, revoked, device_info) VALUES (?, ?, ?, ?, ?, ?, ?)", _, err := DB.Exec("INSERT INTO refresh_tokens(id, user_id, token_hash, expires_at, created_at, revoked, device_info) VALUES (?, ?, ?, ?, ?, ?, ?)",