Updated refresh token endpoint to enable rotation

This commit is contained in:
Benjamin Sherriff
2023-10-18 20:43:02 -04:00
parent c42ecd6591
commit 41522885b1
8 changed files with 318 additions and 104 deletions

View File

@@ -8,13 +8,11 @@ DATABASE_PORT=5432
ACCESS_TOKEN_PRIVATE_KEY= ACCESS_TOKEN_PRIVATE_KEY=
ACCESS_TOKEN_PUBLIC_KEY= ACCESS_TOKEN_PUBLIC_KEY=
ACCESS_TOKEN_EXPIRED_IN=15m ACCESS_TOKEN_MAXAGE=5
ACCESS_TOKEN_MAXAGE=15
REFRESH_TOKEN_PRIVATE_KEY= REFRESH_TOKEN_PRIVATE_KEY=
REFRESH_TOKEN_PUBLIC_KEY= REFRESH_TOKEN_PUBLIC_KEY=
REFRESH_TOKEN_EXPIRED_IN=60m REFRESH_TOKEN_MAXAGE=30
REFRESH_TOKEN_MAXAGE=60
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379

View File

@@ -150,11 +150,10 @@ impl FromRequest for JwtAuth {
}; };
let user_email = match conn.get::<_, String>(access_token_uuid.clone().to_string()) { let user_email = match conn.get::<_, String>(access_token_uuid.clone().to_string()) {
Ok(result) => result, Ok(result) => result,
Err(err) => { Err(_) => {
error!("Failed to get access token from redis: {}", err);
return ready(Err(ActixError::from(ServiceError { return ready(Err(ActixError::from(ServiceError {
status: 500, status: 404,
message: format!("Failed to get access token from redis: {}", err) message: format!("Access token was not found")
}))) })))
} }
}; };
@@ -163,9 +162,9 @@ impl FromRequest for JwtAuth {
Ok(user) => { Ok(user) => {
ready(Ok(JwtAuth { token: access_token_uuid, user: user.into() })) ready(Ok(JwtAuth { token: access_token_uuid, user: user.into() }))
} }
Err(err) => return ready(Err(ActixError::from(ServiceError { Err(_) => return ready(Err(ActixError::from(ServiceError {
status: 500, status: 404,
message: format!("Failed to get user from db: {}", err) message: format!("User was not found")
}))) })))
} }
} }

View File

@@ -3,6 +3,7 @@ use std::env;
use actix_web::{get, post, web, HttpResponse, ResponseError, cookie::{Cookie, time::Duration}, HttpRequest}; use actix_web::{get, post, web, HttpResponse, ResponseError, cookie::{Cookie, time::Duration}, HttpRequest};
use log::error; use log::error;
use redis::AsyncCommands; use redis::AsyncCommands;
use serde::{Serialize, Deserialize};
use siren::ServiceError; use siren::ServiceError;
use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, JwtAuth, verify_token, generate_access_token, generate_refresh_token}, db}; 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<LoginRequest>) -> HttpResponse {
} }
} }
#[derive(Serialize, Deserialize)]
struct RefreshParams {
refresh_token_rotation: Option<bool>
}
#[get("/refresh")] #[get("/refresh")]
async fn refresh(req: HttpRequest) -> HttpResponse { async fn refresh(req: HttpRequest) -> HttpResponse {
let params = match web::Query::<RefreshParams>::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") { let refresh_token = match req.cookie("refresh_token") {
Some(cookie) => cookie.value().to_string(), Some(cookie) => cookie.value().to_string(),
None => return ResponseError::error_response(&ServiceError { None => return ResponseError::error_response(&ServiceError {
@@ -133,6 +147,7 @@ async fn refresh(req: HttpRequest) -> HttpResponse {
message: "Refresh token not found".to_string() message: "Refresh token not found".to_string()
}) })
}; };
let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY") let public_key = env::var("REFRESH_TOKEN_PUBLIC_KEY")
.expect("REFRESH_TOKEN_PUBLIC_KEY must be set"); .expect("REFRESH_TOKEN_PUBLIC_KEY must be set");
let refresh_token_details = match verify_token(&refresh_token, &public_key) { 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<String> = conn.get(refresh_token_details.token_uuid.to_string()).await; let redis_result: redis::RedisResult<String> = conn.get(refresh_token_details.token_uuid.to_string()).await;
let email = match redis_result { let email = match redis_result {
Ok(email) => email, Ok(email) => email,
Err(err) => return ResponseError::error_response(&ServiceError { Err(_) => return ResponseError::error_response(&ServiceError {
status: 500, status: 404,
message: format!("Failed to get refresh token from redis: {}", err) message: format!("Refresh token was not found")
}) })
}; };
@@ -167,6 +182,23 @@ async fn refresh(req: HttpRequest) -> HttpResponse {
} }
}; };
// 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") let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE")
.expect("ACCESS_TOKEN_MAXAGE must be set") .expect("ACCESS_TOKEN_MAXAGE must be set")
.parse::<i64>() .parse::<i64>()
@@ -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(); let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap();
// 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::<i64>()
.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() HttpResponse::Ok()
.cookie(access_cookie) .cookie(access_cookie)
.cookie(logged_in_cookie) .cookie(logged_in_cookie)
.json(JwtAuth { token: access_token_uuid, user: query_user.into() }) .json(JwtAuth { token: access_token_uuid, user: query_user.into() })
}
}, },
Err(err) => return ResponseError::error_response(&err) Err(err) => return ResponseError::error_response(&err)
} }

View File

@@ -1,5 +1,5 @@
import { getRequest, postRequest } from '.'; import { getRequest, postRequest } from '.';
import { ResponseUser } from './auth.types'; import { RegisterUser, ResponseUser } from './auth.types';
export async function login(email: string, password: string): Promise<ResponseUser | undefined> { export async function login(email: string, password: string): Promise<ResponseUser | undefined> {
const response = await postRequest('auth/login', { email, password }, { withCredentials: true }); const response = await postRequest('auth/login', { email, password }, { withCredentials: true });
@@ -10,10 +10,28 @@ export async function login(email: string, password: string): Promise<ResponseUs
} }
} }
export async function register(user: RegisterUser): Promise<boolean> {
const response = await postRequest('auth/register', user, { withCredentials: true });
if (response?.status === 201) {
return true;
} else {
return false;
}
}
export async function logout() { export async function logout() {
return await postRequest('auth/logout', {}, { withCredentials: true }); return await postRequest('auth/logout', {}, { withCredentials: true });
} }
export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseUser | undefined> {
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<ResponseUser | undefined> { export async function me(): Promise<ResponseUser | undefined> {
const response = await getRequest('auth/me', { withCredentials: true }); const response = await getRequest('auth/me', { withCredentials: true });
if (response?.status === 200) { if (response?.status === 200) {

View File

@@ -3,6 +3,13 @@ export interface ResponseUser {
user: User; user: User;
} }
export interface RegisterUser {
email: string;
password: string;
first_name: string;
last_name: string;
}
export interface User { export interface User {
email: string; email: string;
role: string; role: string;

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
// Home page for siren
export default function Page() { export default function Page() {
return <></>; return <div></div>;
} }

View File

@@ -7,6 +7,7 @@ import {
Anchor, Anchor,
Avatar, Avatar,
Button, Button,
Card,
Checkbox, Checkbox,
Container, Container,
Group, Group,
@@ -16,13 +17,15 @@ import {
PasswordInput, PasswordInput,
Text, Text,
TextInput, TextInput,
Title Title,
UnstyledButton
} from '@mantine/core'; } from '@mantine/core';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from '@mantine/form'; 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 { User } from '@/api/auth.types';
import { useToggle } from '@mantine/hooks';
interface HeaderItem { interface HeaderItem {
name: string; name: string;
@@ -68,7 +71,7 @@ const headerItems: HeaderItem[] = [
export default function Topbar() { export default function Topbar() {
const pathName = usePathname(); const pathName = usePathname();
const [showLogin, setShowLogin] = useState(false); const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
const [headers, setHeaders] = useState<HeaderItem[]>([]); const [headers, setHeaders] = useState<HeaderItem[]>([]);
const [user, setUser] = useState<User | undefined>(undefined); const [user, setUser] = useState<User | undefined>(undefined);
useEffect(() => { useEffect(() => {
@@ -108,15 +111,41 @@ export default function Topbar() {
))} ))}
</div> </div>
</div> </div>
<div className='user'> <div className='user-section'>
<Menu shadow='md' width={200} trigger='hover' openDelay={100} closeDelay={400}> {user ? (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
<Menu.Target> <Menu.Target>
<Avatar style={{ cursor: 'pointer' }} /> <UnstyledButton className='user user-button'>
<Group>
<Avatar />
<div style={{ flex: 1 }}>
<Text size='sm' fw={500}>
{user.first_name} {user.last_name}
</Text>
<Text c='dimmed' size='xs'>
{user.role}
</Text>
</div>
</Group>
</UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{!user && <Menu.Item onClick={() => setShowLogin(true)}>Login</Menu.Item>} <Card>
{user && ( <Card.Section h={140} style={{}} />
<Menu.Item <Avatar size={80} radius={80} mx={'auto'} mt={-30} />
<Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name}
</Text>
<Text ta='center' fz='sm' c='dimmed'>
{user.role}
</Text>
<Button
fullWidth
radius='md'
mt='xl'
size='md'
variant='default'
onClick={async () => { onClick={async () => {
const response = await logout(); const response = await logout();
if (response?.status == 200) { if (response?.status == 200) {
@@ -126,28 +155,36 @@ export default function Topbar() {
}} }}
> >
Logout Logout
</Menu.Item> </Button>
)} </Card>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
) : (
<Group className='user'>
<Button onClick={() => toggle('login')}>Login</Button>
<Button variant='outline' onClick={() => toggle('register')}>
Sign up
</Button>
</Group>
)}
</div> </div>
</nav> </nav>
<LoginModal showLogin={showLogin} setShowLogin={setShowLogin} setUser={setUser} /> <LoginModal type={modalType} toggle={toggle} setUser={setUser} />
</> </>
); );
} }
function LoginModal({ interface LoginModalProps {
showLogin, type?: string;
setShowLogin, toggle: any;
setUser
}: {
showLogin: boolean;
setShowLogin: (show: boolean) => void;
setUser: (user: User) => void; setUser: (user: User) => void;
}) { }
function LoginModal({ type, toggle, setUser }: LoginModalProps) {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
firstName: '',
lastName: '',
email: '', email: '',
password: '', password: '',
remember: false remember: false
@@ -155,19 +192,87 @@ function LoginModal({
}); });
function onClose() { function onClose() {
setShowLogin(false); toggle(undefined);
if (!form.values.remember) { if (!form.values.remember) {
form.reset(); form.reset();
} }
} }
return ( return (
<Modal opened={showLogin} onClose={onClose} withCloseButton={false}> <Modal opened={type !== undefined} onClose={onClose} withCloseButton={false}>
{type == 'reset' ? (
<Container>
<Title ta='center'>Reset password</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Enter your email and we will send you a link to reset your password
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={form.onSubmit(async (values) => {
const response = await login(values.email, values.password);
if (response) {
setUser(response.user);
onClose();
}
})}
>
<TextInput label='Email' placeholder='you@example.com' required {...form.getInputProps('email')} />
<Button type='submit' fullWidth mt='xl'>
Reset password
</Button>
</form>
</Paper>
</Container>
) : type == 'register' ? (
<Container>
<Title ta='center'>Create account</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Already have an account?{' '}
<Anchor size='sm' component='button' onClick={() => toggle('login')}>
Sign in
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={form.onSubmit(async (values) => {
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();
}
}
})}
>
<TextInput label='First name' placeholder='John' required {...form.getInputProps('firstName')} />
<TextInput label='Last name' placeholder='Smith' required mt='md' {...form.getInputProps('lastName')} />
<TextInput label='Email' placeholder='you@example.com' required {...form.getInputProps('email')} />
<PasswordInput
label='Password'
placeholder='Your password'
required
mt='md'
{...form.getInputProps('password')}
/>
<Button type='submit' fullWidth mt='xl'>
Sign up
</Button>
</form>
</Paper>
</Container>
) : (
<Container> <Container>
<Title ta='center'>Welcome back!</Title> <Title ta='center'>Welcome back!</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}> <Text c='dimmed' size='sm' ta='center' mt={5}>
Do not have an account yet?{' '} Do not have an account yet?{' '}
<Anchor size='sm' component='button'> <Anchor size='sm' component='button' onClick={() => toggle('register')}>
Create account Create account
</Anchor> </Anchor>
</Text> </Text>
@@ -192,7 +297,7 @@ function LoginModal({
/> />
<Group justify='space-between' mt='lg'> <Group justify='space-between' mt='lg'>
<Checkbox label='Remember me' {...form.getInputProps('remember')} /> <Checkbox label='Remember me' {...form.getInputProps('remember')} />
<Anchor component='button' size='sm'> <Anchor component='button' size='sm' onClick={() => toggle('reset')}>
Forgot password? Forgot password?
</Anchor> </Anchor>
</Group> </Group>
@@ -202,6 +307,7 @@ function LoginModal({
</form> </form>
</Paper> </Paper>
</Container> </Container>
)}
</Modal> </Modal>
); );
} }

View File

@@ -9,11 +9,6 @@
display: flex; display: flex;
} }
.navbar .user {
padding-left: 1em;
padding-right: 1em;
}
.navbar .title { .navbar .title {
padding-left: 2em; padding-left: 2em;
padding-right: 2em; padding-right: 2em;
@@ -25,20 +20,14 @@
margin: auto; margin: auto;
} }
.navbar .avatar {
padding-right: 2em;
margin-top: auto;
margin-bottom: auto;
}
.header-items { .header-items {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.header-items .header-item { .header-items .header-item {
padding-left: 2em; padding-left: 2rem;
padding-right: 2em; padding-right: 2rem;
margin: auto; margin: auto;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
} }
@@ -50,3 +39,23 @@
.header-items .active { .header-items .active {
border-bottom: 2px solid #5f5f5f; 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;
}