Implement Oauth 2.0 token service
This commit is contained in:
parent
b1776b9520
commit
f6e9c082e2
16 changed files with 266 additions and 3 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
7
migrations/V0006__oauth_token.sql
Normal file
7
migrations/V0006__oauth_token.sql
Normal 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
|
||||
);
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
48
src/mastodon_api/oauth/auth.rs
Normal file
48
src/mastodon_api/oauth/auth.rs
Normal 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))
|
||||
})
|
||||
}
|
4
src/mastodon_api/oauth/mod.rs
Normal file
4
src/mastodon_api/oauth/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub mod auth;
|
||||
mod types;
|
||||
pub mod views;
|
||||
mod utils;
|
28
src/mastodon_api/oauth/types.rs
Normal file
28
src/mastodon_api/oauth/types.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
22
src/mastodon_api/oauth/utils.rs
Normal file
22
src/mastodon_api/oauth/utils.rs
Normal 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);
|
||||
}
|
||||
}
|
57
src/mastodon_api/oauth/views.rs
Normal file
57
src/mastodon_api/oauth/views.rs
Normal 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)
|
||||
}
|
|
@ -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
1
src/models/oauth/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod queries;
|
53
src/models/oauth/queries.rs
Normal file
53
src/models/oauth/queries.rs
Normal 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)
|
||||
}
|
Loading…
Reference in a new issue