parent
b3242b84ff
commit
1ec1ba0028
4 changed files with 192 additions and 30 deletions
|
|
@ -148,11 +148,44 @@ async function api(path, options = {}) {
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(fullUrl, {
|
let res = await fetch(fullUrl, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized via Refresh Token
|
||||||
|
if (res.status === 401 && !path.includes('/auth/refresh')) {
|
||||||
|
const refreshToken = localStorage.getItem("chattz_refresh_token");
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh';
|
||||||
|
const refreshRes = await fetch(refreshUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refreshRes.ok) {
|
||||||
|
const newTokens = await refreshRes.json();
|
||||||
|
localStorage.setItem("chattz_token", newTokens.access_token);
|
||||||
|
localStorage.setItem("chattz_refresh_token", newTokens.refresh_token);
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
headers["Authorization"] = `Bearer ${newTokens.access_token}`;
|
||||||
|
res = await fetch(fullUrl, { ...options, headers });
|
||||||
|
} else {
|
||||||
|
// Both tokens invalid/expired, wipe out to force clear state
|
||||||
|
throw new Error("Refresh token expired or invalid");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
localStorage.removeItem("chattz_token");
|
||||||
|
localStorage.removeItem("chattz_refresh_token");
|
||||||
|
location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login";
|
||||||
|
throw new Error("Session expired, please log in again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = "request failed";
|
let detail = "request failed";
|
||||||
try {
|
try {
|
||||||
|
|
@ -1191,9 +1224,14 @@ async function init() {
|
||||||
try {
|
try {
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
const jwtToken = searchParams.get("token");
|
const jwtToken = searchParams.get("token");
|
||||||
if (jwtToken) {
|
const refreshToken = searchParams.get("refresh_token");
|
||||||
localStorage.setItem("chattz_token", jwtToken);
|
|
||||||
|
if (jwtToken || refreshToken) {
|
||||||
|
if (jwtToken) localStorage.setItem("chattz_token", jwtToken);
|
||||||
|
if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken);
|
||||||
|
|
||||||
searchParams.delete("token");
|
searchParams.delete("token");
|
||||||
|
searchParams.delete("refresh_token");
|
||||||
const nextQuery = searchParams.toString();
|
const nextQuery = searchParams.toString();
|
||||||
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
||||||
history.replaceState(null, "", nextUrl);
|
history.replaceState(null, "", nextUrl);
|
||||||
|
|
@ -1210,6 +1248,7 @@ async function init() {
|
||||||
el.logoutBtn.onclick = async () => {
|
el.logoutBtn.onclick = async () => {
|
||||||
await leaveVoice();
|
await leaveVoice();
|
||||||
localStorage.removeItem("chattz_token");
|
localStorage.removeItem("chattz_token");
|
||||||
|
localStorage.removeItem("chattz_refresh_token");
|
||||||
try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
|
try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
|
||||||
location.reload();
|
location.reload();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
50
src/auth.rs
50
src/auth.rs
|
|
@ -14,13 +14,15 @@ use uuid::Uuid;
|
||||||
use crate::{AppState, db};
|
use crate::{AppState, db};
|
||||||
|
|
||||||
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
||||||
const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7;
|
const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes
|
||||||
|
const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
struct SessionClaims {
|
pub struct SessionClaims {
|
||||||
sub: String,
|
pub sub: String,
|
||||||
exp: usize,
|
pub kind: String, // "access" or "refresh"
|
||||||
iat: usize,
|
pub exp: usize,
|
||||||
|
pub iat: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -94,8 +96,8 @@ where
|
||||||
.or_else(|| read_query_token(parts))
|
.or_else(|| read_query_token(parts))
|
||||||
.ok_or_else(|| ApiError::unauthorized("missing jwt token"))?;
|
.ok_or_else(|| ApiError::unauthorized("missing jwt token"))?;
|
||||||
|
|
||||||
let user_id = verify_session(&token, &app.settings.session_secret)
|
let user_id = verify_session(&token, &app.settings.session_secret, "access")
|
||||||
.map_err(|_| ApiError::unauthorized("invalid token"))?;
|
.map_err(|_| ApiError::unauthorized("invalid or expired token"))?;
|
||||||
|
|
||||||
let exists = db::user_exists(&app.db, user_id)
|
let exists = db::user_exists(&app.db, user_id)
|
||||||
.await
|
.await
|
||||||
|
|
@ -138,25 +140,41 @@ pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_jwt_token(user_id: Uuid, secret: &str) -> Result<String> {
|
pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> {
|
||||||
let now = now_ts();
|
let now = now_ts();
|
||||||
let claims = SessionClaims {
|
|
||||||
|
let access_claims = SessionClaims {
|
||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
|
kind: "access".to_string(),
|
||||||
iat: now as usize,
|
iat: now as usize,
|
||||||
exp: (now + SESSION_TTL_SECS) as usize,
|
exp: (now + SESSION_TTL_SECS) as usize,
|
||||||
};
|
};
|
||||||
|
|
||||||
let token = encode(
|
let refresh_claims = SessionClaims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
kind: "refresh".to_string(),
|
||||||
|
iat: now as usize,
|
||||||
|
exp: (now + REFRESH_TTL_SECS) as usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
let access_token = encode(
|
||||||
&Header::default(),
|
&Header::default(),
|
||||||
&claims,
|
&access_claims,
|
||||||
&EncodingKey::from_secret(secret.as_bytes()),
|
&EncodingKey::from_secret(secret.as_bytes()),
|
||||||
)
|
)
|
||||||
.context("failed to encode session token")?;
|
.context("failed to encode access token")?;
|
||||||
|
|
||||||
Ok(token)
|
let refresh_token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&refresh_claims,
|
||||||
|
&EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.context("failed to encode refresh token")?;
|
||||||
|
|
||||||
|
Ok((access_token, refresh_token))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
|
pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result<Uuid> {
|
||||||
let mut validation = Validation::default();
|
let mut validation = Validation::default();
|
||||||
validation.validate_exp = true;
|
validation.validate_exp = true;
|
||||||
|
|
||||||
|
|
@ -167,6 +185,10 @@ fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
|
||||||
)
|
)
|
||||||
.context("failed to decode session token")?;
|
.context("failed to decode session token")?;
|
||||||
|
|
||||||
|
if data.claims.kind != expected_kind {
|
||||||
|
return Err(anyhow!("invalid token kind"));
|
||||||
|
}
|
||||||
|
|
||||||
let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
|
let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
|
||||||
Ok(user_id)
|
Ok(user_id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/auth/login", get(auth_login))
|
.route("/auth/login", get(auth_login))
|
||||||
.route("/auth/callback", get(auth_callback))
|
.route("/auth/callback", get(auth_callback))
|
||||||
|
.route("/auth/refresh", post(auth_refresh))
|
||||||
.route("/auth/logout", post(auth_logout))
|
.route("/auth/logout", post(auth_logout))
|
||||||
.route("/me", get(me))
|
.route("/me", get(me))
|
||||||
.route("/dms", get(list_dm_conversations))
|
.route("/dms", get(list_dm_conversations))
|
||||||
|
|
@ -213,7 +214,8 @@ async fn auth_callback(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
||||||
|
|
||||||
let jwt_token = auth::new_jwt_token(user.id, &state.settings.session_secret)
|
let (access_token, refresh_token) =
|
||||||
|
auth::new_jwt_tokens(user.id, &state.settings.session_secret)
|
||||||
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|
@ -224,7 +226,53 @@ async fn auth_callback(
|
||||||
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
|
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok((headers, Redirect::to(&format!("/?token={}", jwt_token))))
|
Ok((
|
||||||
|
headers,
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/?token={}&refresh_token={}",
|
||||||
|
access_token, refresh_token
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AuthRefreshBody {
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AuthRefreshResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_refresh(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<AuthRefreshBody>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user_id = auth::verify_session(
|
||||||
|
&body.refresh_token,
|
||||||
|
&state.settings.session_secret,
|
||||||
|
"refresh",
|
||||||
|
)
|
||||||
|
.map_err(|_| ApiError::unauthorized("invalid or expired refresh token"))?;
|
||||||
|
|
||||||
|
let exists = db::user_exists(&state.db, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::internal("user verification failed"))?;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return Err(ApiError::unauthorized("user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (access_token, refresh_token) =
|
||||||
|
auth::new_jwt_tokens(user_id, &state.settings.session_secret)
|
||||||
|
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(AuthRefreshResponse {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auth_logout() -> Result<impl IntoResponse, ApiError> {
|
async fn auth_logout() -> Result<impl IntoResponse, ApiError> {
|
||||||
|
|
|
||||||
|
|
@ -110,15 +110,53 @@ const el = {
|
||||||
// --- API Helpers ---
|
// --- API Helpers ---
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const res = await fetch(path, {
|
const headers = {
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
};
|
||||||
credentials: "same-origin",
|
|
||||||
|
const token = localStorage.getItem("chattz_token");
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized via Refresh Token
|
||||||
|
if (res.status === 401 && !path.includes('/auth/refresh')) {
|
||||||
|
const refreshToken = localStorage.getItem("chattz_refresh_token");
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const refreshRes = await fetch('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refreshRes.ok) {
|
||||||
|
const newTokens = await refreshRes.json();
|
||||||
|
localStorage.setItem("chattz_token", newTokens.access_token);
|
||||||
|
localStorage.setItem("chattz_refresh_token", newTokens.refresh_token);
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
headers["Authorization"] = `Bearer ${newTokens.access_token}`;
|
||||||
|
res = await fetch(path, { ...options, headers });
|
||||||
|
} else {
|
||||||
|
// Both tokens invalid/expired
|
||||||
|
throw new Error("Refresh token expired or invalid");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
localStorage.removeItem("chattz_token");
|
||||||
|
localStorage.removeItem("chattz_refresh_token");
|
||||||
|
location.href = "/auth/login";
|
||||||
|
throw new Error("Session expired, please log in again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = "request failed";
|
let detail = "request failed";
|
||||||
try {
|
try {
|
||||||
|
|
@ -523,7 +561,11 @@ function startVoicePresencePolling() {
|
||||||
function initChatWs() {
|
function initChatWs() {
|
||||||
if (state.chatWs) state.chatWs.close();
|
if (state.chatWs) state.chatWs.close();
|
||||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsUrl = `${protocol}//${location.host}/ws`;
|
let wsUrl = `${protocol}//${location.host}/ws`;
|
||||||
|
const token = localStorage.getItem("chattz_token");
|
||||||
|
if (token) {
|
||||||
|
wsUrl += (wsUrl.includes('?') ? '&' : '?') + `token=${token}`;
|
||||||
|
}
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
state.chatWs = ws;
|
state.chatWs = ws;
|
||||||
|
|
||||||
|
|
@ -567,7 +609,12 @@ function initChatWs() {
|
||||||
|
|
||||||
function getVoiceWsUrl(channelId) {
|
function getVoiceWsUrl(channelId) {
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
return `${proto}://${location.host}/channels/${channelId}/voice/ws`;
|
let urlStr = `${proto}://${location.host}/channels/${channelId}/voice/ws`;
|
||||||
|
const token = localStorage.getItem("chattz_token");
|
||||||
|
if (token) {
|
||||||
|
urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`;
|
||||||
|
}
|
||||||
|
return urlStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldInitiateOffer(peerId) {
|
function shouldInitiateOffer(peerId) {
|
||||||
|
|
@ -1159,9 +1206,14 @@ async function init() {
|
||||||
try {
|
try {
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
const jwtToken = searchParams.get("token");
|
const jwtToken = searchParams.get("token");
|
||||||
if (jwtToken) {
|
const refreshToken = searchParams.get("refresh_token");
|
||||||
localStorage.setItem("chattz_token", jwtToken);
|
|
||||||
|
if (jwtToken || refreshToken) {
|
||||||
|
if (jwtToken) localStorage.setItem("chattz_token", jwtToken);
|
||||||
|
if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken);
|
||||||
|
|
||||||
searchParams.delete("token");
|
searchParams.delete("token");
|
||||||
|
searchParams.delete("refresh_token");
|
||||||
const nextQuery = searchParams.toString();
|
const nextQuery = searchParams.toString();
|
||||||
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
||||||
history.replaceState(null, "", nextUrl);
|
history.replaceState(null, "", nextUrl);
|
||||||
|
|
@ -1175,6 +1227,7 @@ async function init() {
|
||||||
el.logoutBtn.onclick = async () => {
|
el.logoutBtn.onclick = async () => {
|
||||||
await leaveVoice();
|
await leaveVoice();
|
||||||
localStorage.removeItem("chattz_token");
|
localStorage.removeItem("chattz_token");
|
||||||
|
localStorage.removeItem("chattz_refresh_token");
|
||||||
try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
|
try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
|
||||||
location.reload();
|
location.reload();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue