From 41522885b192a177d54268c46d434a34ff498bf9 Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 18 Oct 2023 20:43:02 -0400 Subject: [PATCH] Updated refresh token endpoint to enable rotation --- service/.env.TEMPLATE | 6 +- service/src/auth/model.rs | 13 +- service/src/auth/routes.rs | 84 ++++++++- ui/src/api/auth.ts | 20 ++- ui/src/api/auth.types.ts | 7 + ui/src/app/page.tsx | 3 +- ui/src/components/Topbar/index.tsx | 254 ++++++++++++++++++++-------- ui/src/components/Topbar/topbar.css | 35 ++-- 8 files changed, 318 insertions(+), 104 deletions(-) diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE index e28243e..3d99b8d 100644 --- a/service/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -8,13 +8,11 @@ DATABASE_PORT=5432 ACCESS_TOKEN_PRIVATE_KEY= ACCESS_TOKEN_PUBLIC_KEY= -ACCESS_TOKEN_EXPIRED_IN=15m -ACCESS_TOKEN_MAXAGE=15 +ACCESS_TOKEN_MAXAGE=5 REFRESH_TOKEN_PRIVATE_KEY= REFRESH_TOKEN_PUBLIC_KEY= -REFRESH_TOKEN_EXPIRED_IN=60m -REFRESH_TOKEN_MAXAGE=60 +REFRESH_TOKEN_MAXAGE=30 REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/service/src/auth/model.rs b/service/src/auth/model.rs index b8170ac..47f06ec 100644 --- a/service/src/auth/model.rs +++ b/service/src/auth/model.rs @@ -150,11 +150,10 @@ impl FromRequest for JwtAuth { }; let user_email = match conn.get::<_, String>(access_token_uuid.clone().to_string()) { Ok(result) => result, - Err(err) => { - error!("Failed to get access token from redis: {}", err); + Err(_) => { return ready(Err(ActixError::from(ServiceError { - status: 500, - message: format!("Failed to get access token from redis: {}", err) + status: 404, + message: format!("Access token was not found") }))) } }; @@ -163,9 +162,9 @@ impl FromRequest for JwtAuth { Ok(user) => { ready(Ok(JwtAuth { token: access_token_uuid, user: user.into() })) } - Err(err) => return ready(Err(ActixError::from(ServiceError { - status: 500, - message: format!("Failed to get user from db: {}", err) + Err(_) => return ready(Err(ActixError::from(ServiceError { + status: 404, + message: format!("User was not found") }))) } } diff --git a/service/src/auth/routes.rs b/service/src/auth/routes.rs index a9f0f6c..f128f57 100644 --- a/service/src/auth/routes.rs +++ b/service/src/auth/routes.rs @@ -3,6 +3,7 @@ use std::env; use actix_web::{get, post, web, HttpResponse, ResponseError, cookie::{Cookie, time::Duration}, HttpRequest}; use log::error; use redis::AsyncCommands; +use serde::{Serialize, Deserialize}; use siren::ServiceError; use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, generate_access_token, generate_refresh_token}, db}; @@ -124,8 +125,21 @@ async fn login(request: web::Json) -> HttpResponse { } } +#[derive(Serialize, Deserialize)] +struct RefreshParams { + refresh_token_rotation: Option +} + #[get("/refresh")] async fn refresh(req: HttpRequest) -> HttpResponse { + let params = match web::Query::::from_query(req.query_string()) { + Ok(params) => params, + Err(err) => return ResponseError::error_response(&ServiceError { + status: 422, + message: err.to_string() + }) + }; + let refresh_token = match req.cookie("refresh_token") { Some(cookie) => cookie.value().to_string(), None => return ResponseError::error_response(&ServiceError { @@ -133,6 +147,7 @@ async fn refresh(req: HttpRequest) -> HttpResponse { message: "Refresh token not found".to_string() }) }; + let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY") .expect("REFRESH_TOKEN_PUBLIC_KEY must be set"); let refresh_token_details = match verify_token(&refresh_token, &public_key) { @@ -151,9 +166,9 @@ async fn refresh(req: HttpRequest) -> HttpResponse { let redis_result: redis::RedisResult = conn.get(refresh_token_details.token_uuid.to_string()).await; let email = match redis_result { Ok(email) => email, - Err(err) => return ResponseError::error_response(&ServiceError { - status: 500, - message: format!("Failed to get refresh token from redis: {}", err) + Err(_) => return ResponseError::error_response(&ServiceError { + status: 404, + message: format!("Refresh token was not found") }) }; @@ -166,6 +181,23 @@ async fn refresh(req: HttpRequest) -> HttpResponse { return ResponseError::error_response(&err) } }; + + // Delete old auth token if it exists + match req.cookie("access_token") { + Some(cookie) => { + let access_token = cookie.value().to_string(); + let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY") + .expect("ACCESS_TOKEN_PUBLIC_KEY must be set"); + match verify_token(&access_token, &public_key) { + Ok(token_details) => { + let _: redis::RedisResult<()> = conn.del(token_details.token_uuid.to_string()).await; + + }, + Err(_) => {} + }; + }, + None => {} + }; let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE") .expect("ACCESS_TOKEN_MAXAGE must be set") @@ -194,10 +226,54 @@ async fn refresh(req: HttpRequest) -> HttpResponse { let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap(); - HttpResponse::Ok() + // Refresh the refresh token if requested + let refresh_token_rotation = match params.refresh_token_rotation { + Some(refresh_token_rotation) => refresh_token_rotation, + None => false + }; + if refresh_token_rotation { + // Delete the old refresh token + let _: redis::RedisResult<()> = conn.del(refresh_token_details.token_uuid.to_string()).await; + + let refresh_token_details = match generate_refresh_token(&refresh_token_details.email) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to generate refresh token: {}", err); + return ResponseError::error_response(&err) + } + }; + + let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE") + .expect("REFRESH_TOKEN_MAXAGE must be set") + .parse::() + .expect("REFRESH_TOKEN_MAXAGE must be an integer"); + + let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &refresh_token_details.email, (refresh_token_max_age * 60) as usize).await; + if let Err(err) = refresh_result { + error!("Failed to set refresh token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set refresh token in redis: {}", err) + }) + }; + + let refresh_cookie = Cookie::build("refresh_token", refresh_token_details.token.clone().unwrap()) + .path("/") + .max_age(Duration::new(refresh_token_max_age * 60, 0)) + .http_only(true) + .finish(); + + HttpResponse::Ok() + .cookie(refresh_cookie) + .cookie(access_cookie) + .cookie(logged_in_cookie) + .json(JwtAuth { token: access_token_uuid, user: query_user.into() }) + } else { + HttpResponse::Ok() .cookie(access_cookie) .cookie(logged_in_cookie) .json(JwtAuth { token: access_token_uuid, user: query_user.into() }) + } }, Err(err) => return ResponseError::error_response(&err) } diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 18745b2..cee792b 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,5 +1,5 @@ import { getRequest, postRequest } from '.'; -import { ResponseUser } from './auth.types'; +import { RegisterUser, ResponseUser } from './auth.types'; export async function login(email: string, password: string): Promise { const response = await postRequest('auth/login', { email, password }, { withCredentials: true }); @@ -10,10 +10,28 @@ export async function login(email: string, password: string): Promise { + const response = await postRequest('auth/register', user, { withCredentials: true }); + if (response?.status === 201) { + return true; + } else { + return false; + } +} + export async function logout() { return await postRequest('auth/logout', {}, { withCredentials: true }); } +export async function refresh(refresh_token_rotation?: boolean): Promise { + const response = await getRequest('auth/refresh', { withCredentials: true, params: { refresh_token_rotation } }); + if (response?.status === 200) { + return response.data as ResponseUser; + } else { + return undefined; + } +} + export async function me(): Promise { const response = await getRequest('auth/me', { withCredentials: true }); if (response?.status === 200) { diff --git a/ui/src/api/auth.types.ts b/ui/src/api/auth.types.ts index a6b528d..41acd80 100644 --- a/ui/src/api/auth.types.ts +++ b/ui/src/api/auth.types.ts @@ -3,6 +3,13 @@ export interface ResponseUser { user: User; } +export interface RegisterUser { + email: string; + password: string; + first_name: string; + last_name: string; +} + export interface User { email: string; role: string; diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index e892a07..736bead 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -1,5 +1,6 @@ import React from 'react'; +// Home page for siren export default function Page() { - return <>; + return
; } diff --git a/ui/src/components/Topbar/index.tsx b/ui/src/components/Topbar/index.tsx index ded0833..dc92f05 100644 --- a/ui/src/components/Topbar/index.tsx +++ b/ui/src/components/Topbar/index.tsx @@ -7,6 +7,7 @@ import { Anchor, Avatar, Button, + Card, Checkbox, Container, Group, @@ -16,13 +17,15 @@ import { PasswordInput, Text, TextInput, - Title + Title, + UnstyledButton } from '@mantine/core'; import Cookies from 'js-cookie'; import { useEffect, useState } from 'react'; import { useForm } from '@mantine/form'; -import { login, logout, me } from '@/api/auth'; +import { login, register, logout, me } from '@/api/auth'; import { User } from '@/api/auth.types'; +import { useToggle } from '@mantine/hooks'; interface HeaderItem { name: string; @@ -68,7 +71,7 @@ const headerItems: HeaderItem[] = [ export default function Topbar() { const pathName = usePathname(); - const [showLogin, setShowLogin] = useState(false); + const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']); const [headers, setHeaders] = useState([]); const [user, setUser] = useState(undefined); useEffect(() => { @@ -108,46 +111,80 @@ export default function Topbar() { ))} -
- - - - - - {!user && setShowLogin(true)}>Login} - {user && ( - { - const response = await logout(); - if (response?.status == 200) { - Cookies.remove('logged_in'); - setUser(undefined); - } - }} - > - Logout - - )} - - +
+ {user ? ( + + + + + +
+ + {user.first_name} {user.last_name} + + + + {user.role} + +
+
+
+
+ + + + + + {user.first_name} {user.last_name} + + + {user.role} + + + + +
+ ) : ( + + + + + )}
- + ); } -function LoginModal({ - showLogin, - setShowLogin, - setUser -}: { - showLogin: boolean; - setShowLogin: (show: boolean) => void; +interface LoginModalProps { + type?: string; + toggle: any; setUser: (user: User) => void; -}) { +} + +function LoginModal({ type, toggle, setUser }: LoginModalProps) { const form = useForm({ initialValues: { + firstName: '', + lastName: '', email: '', password: '', remember: false @@ -155,53 +192,122 @@ function LoginModal({ }); function onClose() { - setShowLogin(false); + toggle(undefined); if (!form.values.remember) { form.reset(); } } return ( - - - Welcome back! - - Do not have an account yet?{' '} - - Create account - - - - -
{ - const response = await login(values.email, values.password); - if (response) { - setUser(response.user); - onClose(); - } - })} - > - - - - - - Forgot password? - - - + +
+
+ ) : type == 'register' ? ( + + Create account + + Already have an account?{' '} + toggle('login')}> Sign in - - - - + + + + +
{ + const registerResponse = await register({ + first_name: values.firstName, + last_name: values.lastName, + email: values.email, + password: values.password + }); + if (registerResponse) { + const loginResponse = await login(values.email, values.password); + if (loginResponse) { + setUser(loginResponse.user); + onClose(); + } + } + })} + > + + + + + + +
+ + ) : ( + + Welcome back! + + Do not have an account yet?{' '} + toggle('register')}> + Create account + + + + +
{ + const response = await login(values.email, values.password); + if (response) { + setUser(response.user); + onClose(); + } + })} + > + + + + + toggle('reset')}> + Forgot password? + + + + +
+
+ )}
); } diff --git a/ui/src/components/Topbar/topbar.css b/ui/src/components/Topbar/topbar.css index 176f5c8..446a264 100644 --- a/ui/src/components/Topbar/topbar.css +++ b/ui/src/components/Topbar/topbar.css @@ -9,11 +9,6 @@ display: flex; } -.navbar .user { - padding-left: 1em; - padding-right: 1em; -} - .navbar .title { padding-left: 2em; padding-right: 2em; @@ -25,20 +20,14 @@ margin: auto; } -.navbar .avatar { - padding-right: 2em; - margin-top: auto; - margin-bottom: auto; -} - .header-items { display: flex; justify-content: space-between; } .header-items .header-item { - padding-left: 2em; - padding-right: 2em; + padding-left: 2rem; + padding-right: 2rem; margin: auto; border-bottom: 2px solid transparent; } @@ -50,3 +39,23 @@ .header-items .active { border-bottom: 2px solid #5f5f5f; } + +.user-section { + margin-left: 2rem; + margin-right: 2rem; +} + +.user { + display: flex; + justify-content: space-between; + border-radius: 0.5rem; + padding: 0.5rem; + padding-left: 1rem; + padding-right: 1rem; + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.user-button:hover { + background-color: #e6e6e6; +}