Skip to content

Commit

Permalink
Chore: Rework UI structure, automatic authorization
Browse files Browse the repository at this point in the history
Add: Support for changing password
Add: Error banner in the UI
Add: Cookie on successful login using the token or IdToken flows
  • Loading branch information
TobiasDeBruijn committed Dec 29, 2024
1 parent 5fad4c0 commit 8363604
Show file tree
Hide file tree
Showing 39 changed files with 955 additions and 228 deletions.
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ services:
image: mariadb
volumes:
- "./tmp/mariadb-wilford:/var/lib/mysql"
ports:
- "3306:3306"
environment:
- "MARIADB_ROOT_PASSWORD=123"
- "MARIADB_USER=wilford"
Expand Down Expand Up @@ -73,4 +75,4 @@ services:
volumes:
- "./localhost.pem:/etc/ssl/certs/ssl-cert-snakeoil.pem"
- "./localhost-key.pem:/etc/ssl/private/ssl-cert-snakeoil.key"
- "./nginx.conf:/etc/nginx/conf.d/default.conf:ro"
- "./nginx.conf:/etc/nginx/conf.d/default.conf:ro"
2 changes: 2 additions & 0 deletions server/wilford/src/response_types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ mod redirect;
pub use redirect::Redirect;
mod uncached;
pub use uncached::Uncached;
mod set_cookie;
pub use set_cookie::*;
87 changes: 87 additions & 0 deletions server/wilford/src/response_types/set_cookie.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use actix_web::cookie::time::{Duration, OffsetDateTime};
use actix_web::cookie::{Cookie, Expiration, SameSite};
use actix_web::{HttpRequest, HttpResponse, Responder};
use std::borrow::Cow;

pub struct SetCookie<'c, I> {
k: Cow<'c, str>,
v: Cow<'c, str>,
i: I,
}

impl<'c, I> SetCookie<'c, I> {
pub fn new<N, V>(k: N, v: V, i: I) -> Self
where
I: Responder,
N: Into<Cow<'c, str>>,
V: Into<Cow<'c, str>>,
{
Self {
k: k.into(),
v: v.into(),
i,
}
}
}

impl<'c, I> Responder for SetCookie<'c, I>
where
I: Responder,
{
type Body = I::Body;

fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
let mut inner_response = self.i.respond_to(req);
let mut cookie = Cookie::new(self.k, self.v);
cookie.set_secure(true);
cookie.set_expires(Expiration::DateTime(
OffsetDateTime::now_utc() + Duration::days(30),
));
cookie.set_same_site(SameSite::None);
cookie.set_path("/");

inner_response.add_cookie(&cookie).unwrap();
inner_response
}
}

pub struct MaybeCookie<'c, I> {
cookie: Option<SetCookie<'c, I>>,
i: Option<I>,
}

impl<'c, I> MaybeCookie<'c, I> {
pub fn some(cookie: SetCookie<'c, I>) -> Self {
Self {
cookie: Some(cookie),
i: None,
}
}
}

impl<'c, I> MaybeCookie<'c, I>
where
I: Responder,
{
pub fn none(i: I) -> Self {
Self {
cookie: None,
i: Some(i),
}
}
}

impl<'c, I> Responder for MaybeCookie<'c, I>
where
I: Responder,
{
type Body = I::Body;

fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
match (self.cookie, self.i) {
(Some(c), _) => c.respond_to(req),
(None, Some(i)) => i.respond_to(req),
_ => unreachable!(),
}
}
}
48 changes: 32 additions & 16 deletions server/wilford/src/routes/v1/auth/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use database::oauth2_client::{
};
use database::user::User;

use crate::response_types::Redirect;
use crate::response_types::{MaybeCookie, Redirect, SetCookie};
use crate::routes::appdata::{WConfig, WDatabase};
use crate::routes::error::{WebErrorKind, WebResult};
use crate::routes::oauth::{OAuth2AuthorizationResponse, OAuth2Error, OAuth2ErrorKind};
Expand All @@ -29,7 +29,7 @@ pub async fn authorize(
oidc_signing_key: WOidcSigningKey,
config: WConfig,
query: web::Query<Query>,
) -> WebResult<OAuth2AuthorizationResponse<Redirect>> {
) -> WebResult<MaybeCookie<'static, OAuth2AuthorizationResponse<Redirect>>> {
let pending_authorization =
OAuth2PendingAuthorization::get_by_id(&database, &query.authorization)
.await?
Expand All @@ -40,15 +40,17 @@ pub async fn authorize(
.ok_or(WebErrorKind::NotFound)?;

if !query.grant {
return Ok(OAuth2AuthorizationResponse::Err(OAuth2Error::new(
OAuth2ErrorKind::AccessDenied,
&client.redirect_uri,
pending_authorization.state().as_deref(),
return Ok(MaybeCookie::none(OAuth2AuthorizationResponse::Err(
OAuth2Error::new(
OAuth2ErrorKind::AccessDenied,
&client.redirect_uri,
pending_authorization.state().as_deref(),
),
)));
}

let state = pending_authorization.state().clone();
let redirect_uri = match pending_authorization.ty() {
let res = match pending_authorization.ty() {
AuthorizationType::AuthorizationCode => {
let authorization = client
.new_authorization_code(&database, pending_authorization)
Expand All @@ -67,30 +69,38 @@ pub async fn authorize(
state: Option<String>,
}

format!(
let url = format!(
"{}?{}",
client.redirect_uri,
serde_qs::to_string(&RedirectQuery {
code: authorization.code,
state,
})
.expect("Serializing query string"),
)
);

MaybeCookie::none(OAuth2AuthorizationResponse::Ok(Redirect::new(url)))
}
AuthorizationType::Implicit => {
let access_token = new_access_token(&client, pending_authorization, &database).await?;

format!(
let url = format!(
"{}#{}",
client.redirect_uri,
create_implicit_fragment(None, access_token, state),
)
create_implicit_fragment(None, access_token.clone(), state),
);

MaybeCookie::some(SetCookie::new(
"Authorization",
format!("Bearer {}", access_token.token),
OAuth2AuthorizationResponse::Ok(Redirect::new(url)),
))
}
AuthorizationType::IdToken => {
let nonce = pending_authorization.nonce().clone();
let access_token = new_access_token(&client, pending_authorization, &database).await?;

format!(
let url = format!(
"{}#{}",
client.redirect_uri,
create_implicit_fragment(
Expand All @@ -109,14 +119,20 @@ pub async fn authorize(
.tap_err(|e| warn!("Failed to create ID token: {e}"))
.map_err(|_| WebErrorKind::InternalServerError)?
),
access_token,
access_token.clone(),
state
)
)
);

MaybeCookie::some(SetCookie::new(
"Authorization",
format!("Bearer {}", access_token.token),
OAuth2AuthorizationResponse::Ok(Redirect::new(url)),
))
}
};

Ok(OAuth2AuthorizationResponse::Ok(Redirect::new(redirect_uri)))
Ok(res)
}

/// Create a new OAuth2 access token.
Expand Down
20 changes: 16 additions & 4 deletions server/wilford/src/routes/v1/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use database::oauth2_client::OAuth2PendingAuthorization;
use database::user::User;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tracing::{instrument, warn};
use tap::TapFallible;
use tracing::{instrument, warn, warn_span, Instrument};

#[derive(Debug, Deserialize)]
pub struct Request {
Expand All @@ -28,15 +29,17 @@ pub struct Response {
totp_required: bool,
}

#[instrument(skip_all)]
#[instrument(skip(database, config))]
pub async fn login(
database: WDatabase,
config: WConfig,
payload: web::Json<Request>,
) -> WebResult<web::Json<Response>> {
// Get the authorization assocated with the provided token
let authorization = OAuth2PendingAuthorization::get_by_id(&database, &payload.authorization)
.await?
.instrument(warn_span!("OAuth2PendingAuthorization::get_by_id"))
.await
.tap_err(|e| warn!("{e}"))?
.ok_or(WebErrorKind::NotFound)?;

// Get the provider backend
Expand All @@ -49,7 +52,9 @@ pub async fn login(
&payload.password,
payload.totp_code.as_deref(),
)
.instrument(warn_span!("auth_provider::validate_credentials"))
.await
.tap_err(|e| warn!("{e}"))
{
Err(AuthorizationError::InvalidCredentials) => {
return Ok(web::Json(Response {
Expand Down Expand Up @@ -96,15 +101,21 @@ pub async fn login(
// For optimizations, we evaluate the is_admin check first, followed by the scope check. Due to
// short-circuiting behaviour, the scope check is only evaluated if the user is _not_ an admin.
// We use the lambda function to reduce the complecity of the if statement.
let scope_check = || are_scopes_allowed(&database, &authorization, &user_information);
let scope_check = || {
are_scopes_allowed(&database, &authorization, &user_information)
.instrument(warn_span!("scope_check"))
};

if !user_information.is_admin && !scope_check().await? {
return Err(WebErrorKind::Forbidden.into());
}

// Mark the authorization as authorized.
authorization
.set_user_id(&database, &user_information.id)
.instrument(warn_span!("authorization::set_user_id"))
.await
.tap_err(|e| warn!("{e}"))
.map_err(|_| WebErrorKind::BadRequest)?;

Ok(web::Json(Response {
Expand All @@ -113,6 +124,7 @@ pub async fn login(
}))
}

#[instrument(skip_all)]
async fn are_scopes_allowed(
database: &Database,
authorization: &OAuth2PendingAuthorization,
Expand Down
5 changes: 5 additions & 0 deletions server/wilford/src/routes/v1/user/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod info;
mod list;
mod permitted_scopes;
mod register;
mod supports_password_change;

pub struct Router;

Expand All @@ -21,6 +22,10 @@ impl Routable for Router {
"/change-password",
web::post().to(change_password::change_password),
)
.route(
"/supports-password-change",
web::get().to(supports_password_change::supports_password_change),
)
.route("/register", web::post().to(register::register)),
);
}
Expand Down
24 changes: 24 additions & 0 deletions server/wilford/src/routes/v1/user/supports_password_change.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use crate::authorization::combined::CombinedAuthorizationProvider;
use crate::authorization::AuthorizationProvider;
use crate::routes::auth::Auth;
use crate::routes::{WConfig, WDatabase};
use actix_web::web;
use serde::Serialize;

#[derive(Serialize)]
pub struct Response {
/// Whether a password change is supported
password_change_supported: bool,
}

/// Check if a password change is supported
pub async fn supports_password_change(
config: WConfig,
database: WDatabase,
_: Auth,
) -> web::Json<Response> {
let provider = CombinedAuthorizationProvider::new(&config, &database);
web::Json(Response {
password_change_supported: provider.supports_password_change(),
})
}
21 changes: 21 additions & 0 deletions ui/src/components/ErrorBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<template>
<MaterialBanner
title="Error"
type="error"
icon="mdi-alert-circle-outline"
:text="modelValue"
@close="$emit('update', undefined)"
/>
</template>

<script lang="ts">
import {defineComponent} from "vue"
import MaterialBanner from "@/components/MaterialBanner.vue";
export default defineComponent({
components: {MaterialBanner},
props: {
modelValue: String
}
})
</script>
Loading

0 comments on commit 8363604

Please sign in to comment.