Implement Oauth 2.0 token service

This commit is contained in:
silverpill 2021-10-01 00:35:52 +00:00
parent b1776b9520
commit f6e9c082e2
16 changed files with 266 additions and 3 deletions

16
Cargo.lock generated
View file

@ -312,6 +312,17 @@ dependencies = [
"syn",
]
[[package]]
name = "actix-web-httpauth"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c3b11a07a3df3f7970fd8bd38cc66998b5549f507c54cc64c6e843bc82d6358"
dependencies = [
"actix-web",
"base64 0.13.0",
"futures-util",
]
[[package]]
name = "adler"
version = "1.0.2"
@ -1838,6 +1849,7 @@ dependencies = [
"actix-rt",
"actix-session",
"actix-web",
"actix-web-httpauth",
"ammonia",
"base64 0.13.0",
"chrono",
@ -3097,9 +3109,9 @@ dependencies = [
[[package]]
name = "subtle"
version = "2.4.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "syn"

View file

@ -14,6 +14,7 @@ actix-cors = "0.5.4"
actix-files = "0.5.0"
actix-session = "0.4.1"
actix-web = "3.3.2"
actix-web-httpauth = "0.5.1"
# Used for managing async tasks
actix-rt = "1.1.1"
# Used for HTML sanitization

View file

@ -67,6 +67,7 @@ Endpoints are similar to Mastodon API:
```
GET /api/v1/accounts/{account_id}
GET /api/v1/accounts/verify_credentials
PATCH /api/v1/accounts/update_credentials
GET /api/v1/accounts/relationships
POST /api/v1/accounts/{account_id}/follow

View file

@ -0,0 +1,7 @@
CREATE TABLE oauth_token (
id SERIAL PRIMARY KEY,
owner_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE,
token VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);

View file

@ -63,4 +63,12 @@ CREATE TABLE media_attachment (
ipfs_cid VARCHAR(200),
post_id UUID REFERENCES post (id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
);
CREATE TABLE oauth_token (
id SERIAL PRIMARY KEY,
owner_id UUID NOT NULL REFERENCES user_account (id) ON DELETE CASCADE,
token VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);

View file

@ -15,6 +15,8 @@ use mitra::mastodon_api::accounts::views::account_api_scope;
use mitra::mastodon_api::directory::views::profile_directory;
use mitra::mastodon_api::instance::views as instance_api;
use mitra::mastodon_api::media::views::media_api_scope;
use mitra::mastodon_api::oauth::auth::create_auth_error_handler;
use mitra::mastodon_api::oauth::views::oauth_api_scope;
use mitra::mastodon_api::search::views::search;
use mitra::mastodon_api::statuses::views::status_api_scope;
use mitra::mastodon_api::timelines::views as timeline_api;
@ -65,6 +67,7 @@ async fn main() -> std::io::Result<()> {
.wrap(ActixLogger::new("%r : %s : %{r}a"))
.wrap(cors_config)
.wrap(cookie_config)
.wrap(create_auth_error_handler())
.data(web::PayloadConfig::default().limit(MAX_UPLOAD_SIZE))
.data(web::JsonConfig::default().limit(MAX_UPLOAD_SIZE))
.data(config.clone())
@ -77,6 +80,7 @@ async fn main() -> std::io::Result<()> {
"/contracts",
config.contract_dir.clone(),
))
.service(oauth_api_scope())
.service(user_api::create_user_view)
.service(user_api::login_view)
.service(user_api::current_user_view)

View file

@ -1,5 +1,6 @@
use actix_session::Session;
use actix_web::{get, post, patch, web, HttpResponse, Scope};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use serde::Deserialize;
use uuid::Uuid;
@ -13,6 +14,7 @@ use crate::config::Config;
use crate::database::{Pool, get_database_client};
use crate::errors::HttpError;
use crate::mastodon_api::statuses::types::Status;
use crate::mastodon_api::oauth::auth::get_current_user as get_current_user_;
use crate::mastodon_api::users::auth::get_current_user;
use crate::models::posts::queries::get_posts_by_author;
use crate::models::profiles::queries::{
@ -35,6 +37,18 @@ async fn get_account(
Ok(HttpResponse::Ok().json(account))
}
#[get("/verify_credentials")]
async fn verify_credentials(
config: web::Data<Config>,
db_pool: web::Data<Pool>,
auth: BearerAuth,
) -> Result<HttpResponse, HttpError> {
let db_client = &**get_database_client(&db_pool).await?;
let user = get_current_user_(db_client, auth.token()).await?;
let account = Account::from_profile(user.profile, &config.instance_url());
Ok(HttpResponse::Ok().json(account))
}
#[patch("/update_credentials")]
async fn update_credentials(
config: web::Data<Config>,
@ -198,6 +212,7 @@ pub fn account_api_scope() -> Scope {
web::scope("/api/v1/accounts")
// Routes without account ID
.service(get_relationships)
.service(verify_credentials)
.service(update_credentials)
// Routes with account ID
.service(get_account)

View file

@ -2,6 +2,7 @@ pub mod accounts;
pub mod directory;
pub mod instance;
pub mod media;
pub mod oauth;
pub mod search;
pub mod statuses;
pub mod timelines;

View file

@ -0,0 +1,48 @@
use actix_web::{
body::{Body, BodySize, MessageBody, ResponseBody},
http::StatusCode,
middleware::errhandlers::{ErrorHandlerResponse, ErrorHandlers},
};
use actix_web::dev::ServiceResponse;
use serde_json::json;
use tokio_postgres::GenericClient;
use crate::errors::{DatabaseError, HttpError};
use crate::models::oauth::queries::get_user_by_oauth_token;
use crate::models::users::types::User;
pub async fn get_current_user(
db_client: &impl GenericClient,
token: &str,
) -> Result<User, HttpError> {
let user = get_user_by_oauth_token(db_client, token).await.map_err(|err| {
match err {
DatabaseError::NotFound(_) => {
HttpError::SessionError("access token is invalid")
},
_ => HttpError::InternalError,
}
})?;
Ok(user)
}
/// Error handler for 401 Unauthorized
pub fn create_auth_error_handler<B: MessageBody>() -> ErrorHandlers<B> {
ErrorHandlers::new()
.handler(StatusCode::UNAUTHORIZED, |mut response: ServiceResponse<B>| {
response = response.map_body(|_, body| {
if let ResponseBody::Body(data) = &body {
if let BodySize::Empty = data.size() {
// Insert error description if response body is empty
// https://github.com/actix/actix-extras/issues/156
let error_data = json!({
"message": "auth header is not present",
});
return ResponseBody::Body(Body::from(error_data)).into_body();
}
}
body
});
Ok(ErrorHandlerResponse::Response(response))
})
}

View file

@ -0,0 +1,4 @@
pub mod auth;
mod types;
pub mod views;
mod utils;

View file

@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct TokenRequest {
pub grant_type: String,
pub username: String, // wallet address
pub password: String,
}
/// https://docs.joinmastodon.org/entities/token/
#[derive(Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub scope: String,
pub created_at: i64,
}
impl TokenResponse {
pub fn new(access_token: String, created_at: i64) -> Self {
Self {
access_token,
token_type: "Bearer".to_string(),
scope: "read write follow".to_string(),
created_at,
}
}
}

View file

@ -0,0 +1,22 @@
use base64;
use rand;
use rand::prelude::*;
const ACCESS_TOKEN_SIZE: usize = 20;
pub fn generate_access_token() -> String {
let mut rng = rand::thread_rng();
let value: [u8; ACCESS_TOKEN_SIZE] = rng.gen();
base64::encode_config(value, base64::URL_SAFE_NO_PAD)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_access_token() {
let token = generate_access_token();
assert!(token.len() > ACCESS_TOKEN_SIZE);
}
}

View file

@ -0,0 +1,57 @@
use actix_web::{post, web, HttpResponse, Scope as ActixScope};
use chrono::{Duration, Utc};
use crate::database::{Pool, get_database_client};
use crate::errors::{HttpError, ValidationError};
use crate::models::oauth::queries::save_oauth_token;
use crate::models::users::queries::get_user_by_wallet_address;
use crate::utils::crypto::verify_password;
use super::types::{TokenRequest, TokenResponse};
use super::utils::generate_access_token;
const ACCESS_TOKEN_EXPIRES_IN: i64 = 86400 * 7;
/// OAuth 2.0 Password Grant
/// https://oauth.net/2/grant-types/password/
#[post("/token")]
async fn token_view(
db_pool: web::Data<Pool>,
request_data: web::Json<TokenRequest>,
) -> Result<HttpResponse, HttpError> {
if request_data.grant_type != "password".to_string() {
Err(ValidationError("unsupported grant type"))?;
}
let db_client = &**get_database_client(&db_pool).await?;
let user = get_user_by_wallet_address(
db_client,
&request_data.username,
).await?;
let password_correct = verify_password(
&user.password_hash,
&request_data.password,
).map_err(|_| HttpError::InternalError)?;
if !password_correct {
// Invalid signature/password
Err(ValidationError("incorrect password"))?;
}
let access_token = generate_access_token();
let created_at = Utc::now();
let expires_at = created_at + Duration::seconds(ACCESS_TOKEN_EXPIRES_IN);
save_oauth_token(
db_client,
&user.id,
&access_token,
&created_at,
&expires_at,
).await?;
let token_response = TokenResponse::new(
access_token,
created_at.timestamp(),
);
Ok(HttpResponse::Ok().json(token_response))
}
pub fn oauth_api_scope() -> ActixScope {
web::scope("/oauth")
.service(token_view)
}

View file

@ -1,5 +1,6 @@
pub mod attachments;
mod cleanup;
pub mod oauth;
pub mod posts;
pub mod profiles;
pub mod relationships;

1
src/models/oauth/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod queries;

View file

@ -0,0 +1,53 @@
use chrono::{DateTime, Utc};
use tokio_postgres::GenericClient;
use uuid::Uuid;
use crate::errors::DatabaseError;
use crate::models::profiles::types::DbActorProfile;
use crate::models::users::types::{DbUser, User};
pub async fn save_oauth_token(
db_client: &impl GenericClient,
owner_id: &Uuid,
access_token: &str,
created_at: &DateTime<Utc>,
expires_at: &DateTime<Utc>,
) -> Result<(), DatabaseError> {
db_client.execute(
"
INSERT INTO oauth_token (owner_id, token, created_at, expires_at)
VALUES ($1, $2, $3, $4)
",
&[&owner_id, &access_token, &created_at, &expires_at],
).await?;
Ok(())
}
pub async fn get_user_by_oauth_token(
db_client: &impl GenericClient,
access_token: &str,
) -> Result<User, DatabaseError> {
let maybe_row = db_client.query_opt(
"
SELECT user_account, actor_profile
FROM oauth_token
JOIN user_account ON oauth_token.owner_id = user_account.id
JOIN actor_profile ON user_account.id = actor_profile.id
WHERE
oauth_token.token = $1
AND oauth_token.expires_at > now()
",
&[&access_token],
).await?;
let row = maybe_row.ok_or(DatabaseError::NotFound("user"))?;
let db_user: DbUser = row.try_get("user_account")?;
let db_profile: DbActorProfile = row.try_get("actor_profile")?;
let user = User {
id: db_user.id,
wallet_address: db_user.wallet_address,
password_hash: db_user.password_hash,
private_key: db_user.private_key,
profile: db_profile,
};
Ok(user)
}