diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c01cf..b92fe06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Added `/api/v1/apps` endpoint. +- Added OAuth authorization page. - Documented `http_cors_allowlist` configuration parameter. ### Changed - Allow `instance_uri` configuration value to contain URI scheme. +- Changed `Content-Security-Policy` header value in nginx config examples. ## [1.13.1] - 2023-02-09 diff --git a/contrib/mitra-alt-fe.nginx b/contrib/mitra-alt-fe.nginx index 0e9ec26..cef9587 100644 --- a/contrib/mitra-alt-fe.nginx +++ b/contrib/mitra-alt-fe.nginx @@ -32,7 +32,8 @@ server { add_header Strict-Transport-Security "max-age=63072000" always; # script-src unsafe-inline required by MetaMask - add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + # style-src oauth-authorization required by OAuth authorization page + add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'nonce-oauth-authorization'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; add_header X-Content-Type-Options "nosniff"; client_max_body_size 10M; diff --git a/contrib/mitra.nginx b/contrib/mitra.nginx index d9b144f..c8d0a62 100644 --- a/contrib/mitra.nginx +++ b/contrib/mitra.nginx @@ -32,7 +32,8 @@ server { add_header Strict-Transport-Security "max-age=63072000" always; # script-src unsafe-inline required by MetaMask - add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + # style-src oauth-authorization required by OAuth authorization page + add_header Content-Security-Policy "default-src 'none'; connect-src 'self'; img-src 'self' data:; media-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'nonce-oauth-authorization'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; add_header X-Content-Type-Options "nosniff"; client_max_body_size 10M; diff --git a/migrations/V0043__oauth_authorization.sql b/migrations/V0043__oauth_authorization.sql new file mode 100644 index 0000000..c3906bf --- /dev/null +++ b/migrations/V0043__oauth_authorization.sql @@ -0,0 +1,9 @@ +CREATE TABLE oauth_authorization ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code VARCHAR(100) UNIQUE NOT NULL, + user_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE, + application_id INTEGER NOT NULL REFERENCES oauth_application (id) ON DELETE CASCADE, + scopes VARCHAR(200) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL +); diff --git a/migrations/schema.sql b/migrations/schema.sql index ffac55c..37a672e 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -62,6 +62,16 @@ CREATE TABLE oauth_application ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE oauth_authorization ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code VARCHAR(100) UNIQUE NOT NULL, + user_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE, + application_id INTEGER NOT NULL REFERENCES oauth_application (id) ON DELETE CASCADE, + scopes VARCHAR(200) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL +); + CREATE TABLE oauth_token ( id SERIAL PRIMARY KEY, owner_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE, diff --git a/src/mastodon_api/oauth/types.rs b/src/mastodon_api/oauth/types.rs index 4f2482b..c7c236d 100644 --- a/src/mastodon_api/oauth/types.rs +++ b/src/mastodon_api/oauth/types.rs @@ -1,4 +1,19 @@ use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct AuthorizationRequest { + pub username: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct AuthorizationQueryParams { + pub response_type: String, + pub client_id: Uuid, + pub redirect_uri: String, + pub scope: String, +} #[derive(Deserialize)] pub struct TokenRequest { diff --git a/src/mastodon_api/oauth/utils.rs b/src/mastodon_api/oauth/utils.rs index 2598bbc..affad95 100644 --- a/src/mastodon_api/oauth/utils.rs +++ b/src/mastodon_api/oauth/utils.rs @@ -2,6 +2,42 @@ use base64; use rand; use rand::prelude::*; +pub fn render_authorization_page() -> String { + let page = r#" + + + + + + Authorization + + + +
+ +
+ +
+ +
+ + +"#.to_string(); + page +} + const ACCESS_TOKEN_SIZE: usize = 20; pub fn generate_access_token() -> String { diff --git a/src/mastodon_api/oauth/views.rs b/src/mastodon_api/oauth/views.rs index 90319ba..5049e13 100644 --- a/src/mastodon_api/oauth/views.rs +++ b/src/mastodon_api/oauth/views.rs @@ -1,4 +1,11 @@ -use actix_web::{post, web, HttpResponse, Scope as ActixScope}; +use actix_web::{ + get, + post, + web, + HttpResponse, + Scope as ActixScope, + http::header as http_header, +}; use actix_web_httpauth::extractors::bearer::BearerAuth; use chrono::{Duration, Utc}; @@ -7,7 +14,9 @@ use crate::database::{get_database_client, DatabaseError, DbPool}; use crate::errors::{HttpError, ValidationError}; use crate::ethereum::eip4361::verify_eip4361_signature; use crate::models::oauth::queries::{ + create_oauth_authorization, delete_oauth_token, + get_oauth_app_by_client_id, save_oauth_token, }; use crate::models::users::queries::{ @@ -17,8 +26,79 @@ use crate::models::users::queries::{ use crate::utils::currencies::{validate_wallet_address, Currency}; use crate::utils::passwords::verify_password; use super::auth::get_current_user; -use super::types::{RevocationRequest, TokenRequest, TokenResponse}; -use super::utils::generate_access_token; +use super::types::{ + AuthorizationRequest, + AuthorizationQueryParams, + RevocationRequest, + TokenRequest, + TokenResponse, +}; +use super::utils::{ + generate_access_token, + render_authorization_page, +}; + +#[get("/authorize")] +async fn authorization_page_view() -> HttpResponse { + let page = render_authorization_page(); + HttpResponse::Ok() + .content_type("text/html") + .body(page) +} + +const AUTHORIZATION_CODE_EXPIRES_IN: i64 = 86400 * 30; + +#[post("/authorize")] +async fn authorize_view( + db_pool: web::Data, + form_data: web::Form, + query_params: web::Query, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let user = get_user_by_name(db_client, &form_data.username).await?; + let password_hash = user.password_hash.as_ref() + .ok_or(ValidationError("password auth is disabled"))?; + let password_correct = verify_password( + password_hash, + &form_data.password, + ).map_err(|_| HttpError::InternalError)?; + if !password_correct { + return Err(ValidationError("incorrect password").into()); + }; + if query_params.response_type != "code" { + return Err(ValidationError("invalid response type").into()); + }; + let oauth_app = get_oauth_app_by_client_id( + db_client, + &query_params.client_id, + ).await?; + if oauth_app.redirect_uri != query_params.redirect_uri { + return Err(ValidationError("invalid redirect_uri parameter").into()); + }; + + let authorization_code = generate_access_token(); + let created_at = Utc::now(); + let expires_at = created_at + Duration::seconds(AUTHORIZATION_CODE_EXPIRES_IN); + create_oauth_authorization( + db_client, + &authorization_code, + &user.id, + oauth_app.id, + &query_params.scope.replace('+', " "), + &created_at, + &expires_at, + ).await?; + + let redirect_uri = format!( + "{}?code={}", + oauth_app.redirect_uri, + authorization_code, + ); + let response = HttpResponse::Found() + .append_header((http_header::LOCATION, redirect_uri)) + .finish(); + Ok(response) +} const ACCESS_TOKEN_EXPIRES_IN: i64 = 86400 * 7; @@ -114,6 +194,8 @@ async fn revoke_token_view( pub fn oauth_api_scope() -> ActixScope { web::scope("/oauth") + .service(authorization_page_view) + .service(authorize_view) .service(token_view) .service(revoke_token_view) } diff --git a/src/models/oauth/queries.rs b/src/models/oauth/queries.rs index 1d623bf..e7eca32 100644 --- a/src/models/oauth/queries.rs +++ b/src/models/oauth/queries.rs @@ -40,6 +40,56 @@ pub async fn create_oauth_app( Ok(app) } +pub async fn get_oauth_app_by_client_id( + db_client: &impl DatabaseClient, + client_id: &Uuid, +) -> Result { + let maybe_row = db_client.query_opt( + " + SELECT oauth_application + FROM oauth_application + WHERE client_id = $1 + ", + &[&client_id], + ).await?; + let row = maybe_row.ok_or(DatabaseError::NotFound("oauth application"))?; + let app = row.try_get("oauth_application")?; + Ok(app) +} + +pub async fn create_oauth_authorization( + db_client: &impl DatabaseClient, + authorization_code: &str, + user_id: &Uuid, + application_id: i32, + scopes: &str, + created_at: &DateTime, + expires_at: &DateTime, +) -> Result<(), DatabaseError> { + db_client.execute( + " + INSERT INTO oauth_authorization ( + code, + user_id, + application_id, + scopes, + created_at, + expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6) + ", + &[ + &authorization_code, + &user_id, + &application_id, + &scopes, + &created_at, + &expires_at, + ], + ).await?; + Ok(()) +} + pub async fn save_oauth_token( db_client: &impl DatabaseClient, owner_id: &Uuid, @@ -141,6 +191,31 @@ mod tests { assert_eq!(app.app_name, "My App"); } + #[tokio::test] + #[serial] + async fn test_create_oauth_authorization() { + let db_client = &mut create_test_database().await; + let user_data = UserCreateData { + username: "test".to_string(), + ..Default::default() + }; + let user = create_user(db_client, user_data).await.unwrap(); + let app_data = DbOauthAppData { + app_name: "My App".to_string(), + ..Default::default() + }; + let app = create_oauth_app(db_client, app_data).await.unwrap(); + create_oauth_authorization( + db_client, + "code", + &user.id, + app.id, + "read write", + &Utc::now(), + &Utc::now(), + ).await.unwrap(); + } + #[tokio::test] #[serial] async fn test_delete_oauth_token() {