Compare commits
3 Commits
v1.0.2
...
fabe5319ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
fabe5319ae
|
|||
|
e2926df62c
|
|||
|
ea8ea45c4c
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@ appdata
|
||||
.idea
|
||||
*.exe
|
||||
*.cmd
|
||||
.run
|
||||
.run
|
||||
*.out
|
||||
49
auth/jwt.go
49
auth/jwt.go
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -12,6 +13,12 @@ type Claims struct {
|
||||
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) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
@@ -25,8 +32,26 @@ func GenerateJWT(userID, role string, secret []byte) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
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) {
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
@@ -35,7 +60,29 @@ func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
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
|
||||
|
||||
@@ -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 .
|
||||
@@ -39,6 +39,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (document.getElementById('items-table-body')) loadItems();
|
||||
if (document.getElementById('locations-table-body')) loadLocations();
|
||||
if (document.getElementById('projects-table-body')) loadProjects();
|
||||
if (document.getElementById('account-settings-content')) loadAccountSettings();
|
||||
|
||||
loadProfile();
|
||||
});
|
||||
@@ -481,4 +482,247 @@ async function loadProfile() {
|
||||
} catch (e) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,63 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById("login-form");
|
||||
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;
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
errorBox.style.display = "none";
|
||||
|
||||
const username = document.getElementById("username").value;
|
||||
const password = document.getElementById("password").value;
|
||||
submitButton.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
let response;
|
||||
|
||||
if (pendingTwoFactorToken) {
|
||||
response = await fetch("/api/login/2fa", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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) {
|
||||
const text = await response.text();
|
||||
@@ -26,17 +67,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
|
||||
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`;
|
||||
if (data.requires_2fa) {
|
||||
switchToTwoFactorMode(data.two_factor_token);
|
||||
return;
|
||||
}
|
||||
|
||||
storeTokens(data);
|
||||
window.location.href = "/dashboard";
|
||||
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message || "Login failed.";
|
||||
errorBox.style.display = "block";
|
||||
showError(err.message);
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,10 @@ var projects = template.Must(template.ParseFiles(
|
||||
"frontend/htmx/contents/dash/base.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"))
|
||||
|
||||
func Home(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -51,7 +55,6 @@ func Home(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
@@ -142,6 +145,17 @@ func Projects(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
@@ -151,7 +165,6 @@ func init() {
|
||||
minifier.AddFunc("text/css", css.Minify)
|
||||
minifier.AddFunc("text/javascript", js.Minify)
|
||||
}
|
||||
|
||||
func Assets(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/assets/")
|
||||
fullPath := filepath.Join("frontend/assets", path)
|
||||
|
||||
139
frontend/htmx/contents/dash/account_settings.html
Normal file
139
frontend/htmx/contents/dash/account_settings.html
Normal 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 }}
|
||||
@@ -24,12 +24,18 @@
|
||||
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="password-group">
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
|
||||
</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>
|
||||
|
||||
<div id="error" class="message error"></div>
|
||||
|
||||
20
go.mod
20
go.mod
@@ -5,20 +5,22 @@ go 1.26
|
||||
require (
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
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
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.1.0 // 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/tdewolff/parse/v2 v2.8.12 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/libc v1.37.6 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/sqlite v1.28.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
modernc.org/libc v1.73.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.52.0 // indirect
|
||||
)
|
||||
|
||||
34
go.sum
34
go.sum
@@ -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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
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.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/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/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/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
||||
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.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.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
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/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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/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/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/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/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
|
||||
@@ -2,19 +2,23 @@ package handlers
|
||||
|
||||
import (
|
||||
"MiauInv/auth"
|
||||
"MiauInv/config"
|
||||
"MiauInv/models"
|
||||
"MiauInv/storage"
|
||||
"MiauInv/util"
|
||||
utils "MiauInv/util"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"image/png"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var cfg, _ = config.LoadConfig()
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func APIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var user models.User
|
||||
@@ -86,76 +90,407 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret)
|
||||
if err != nil {
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||
http.Error(w, "Could not generate token", http.StatusInternalServerError)
|
||||
if user.TwoFactorEnabled {
|
||||
twoFactorToken, err := auth.GeneratePurposeJWT(user.ID, "2fa_login", secret, 5*time.Minute)
|
||||
if err != nil {
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||
http.Error(w, "Could not generate 2FA challenge", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"requires_2fa": true,
|
||||
"two_factor_token": twoFactorToken,
|
||||
})
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
|
||||
return
|
||||
}
|
||||
|
||||
refreshTokenPlain, err := utils.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||
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{
|
||||
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
|
||||
}
|
||||
issueLoginSession(w, r, user)
|
||||
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) {
|
||||
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||
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)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
clearAuthCookies(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func TestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := utils.IsLoggedIn(w, r)
|
||||
@@ -185,9 +521,19 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
|
||||
if r.Body != nil {
|
||||
_ = 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
|
||||
}
|
||||
|
||||
@@ -204,41 +550,14 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
accessToken, _ := auth.GenerateJWT(tokenRow.UserID, user.Role, []byte(os.Getenv("JWT_SECRET")))
|
||||
|
||||
if err = json.NewEncoder(w).Encode(map[string]string{
|
||||
"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
|
||||
}
|
||||
issueLoginSession(w, r, user)
|
||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
recoveryCodesRemaining := 0
|
||||
if user.TwoFactorEnabled {
|
||||
if count, err := storage.CountUnusedRecoveryCodes(user.ID); err == nil {
|
||||
recoveryCodesRemaining = count
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"avatar_url": "",
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"avatar_url": "",
|
||||
"two_factor_enabled": user.TwoFactorEnabled,
|
||||
"recovery_codes_remaining": recoveryCodesRemaining,
|
||||
})
|
||||
if err != nil {
|
||||
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 + ")")
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
TwoFactorEnabled bool `json:"two_factor_enabled"`
|
||||
TwoFactorSecret string `json:"-"`
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ func (this *Server) Run() {
|
||||
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
|
||||
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
|
||||
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"))
|
||||
if this.AllowRegistration {
|
||||
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
||||
@@ -82,10 +83,17 @@ func (this *Server) Run() {
|
||||
// API
|
||||
//
|
||||
mux.HandleFunc("/api/login", handlers.APILogin)
|
||||
mux.HandleFunc("/api/login/2fa", handlers.APILoginTwoFactor)
|
||||
mux.HandleFunc("/api/refresh", handlers.RefreshToken)
|
||||
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.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 {
|
||||
mux.HandleFunc("/api/register", handlers.APIRegister)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package storage
|
||||
|
||||
import (
|
||||
"MiauInv/models"
|
||||
utils "MiauInv/util"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
)
|
||||
@@ -27,7 +29,9 @@ func InitDB(filepath string) error {
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
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 (
|
||||
@@ -41,6 +45,16 @@ func InitDB(filepath string) error {
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -84,7 +98,26 @@ func InitDB(filepath string) error {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return err
|
||||
if err := ensureUserTwoFactorColumns(); err != nil {
|
||||
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
|
||||
@@ -93,18 +126,161 @@ func AddUser(user *models.User) error {
|
||||
return err
|
||||
}
|
||||
func GetUserByUsername(username string) (models.User, error) {
|
||||
row := DB.QueryRow("SELECT * FROM users WHERE username = ?", strings.ToLower(username))
|
||||
var user models.User
|
||||
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role)
|
||||
return user, err
|
||||
row := DB.QueryRow(`
|
||||
SELECT id, username, password, role, two_factor_enabled, two_factor_secret
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
`, strings.ToLower(username))
|
||||
return scanUser(row)
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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 (?, ?, ?, ?, ?, ?, ?)",
|
||||
|
||||
Reference in New Issue
Block a user