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",
|
"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]]
|
[[package]]
|
||||||
name = "adler"
|
name = "adler"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
@ -1838,6 +1849,7 @@ dependencies = [
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-session",
|
"actix-session",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"actix-web-httpauth",
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"base64 0.13.0",
|
"base64 0.13.0",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
@ -3097,9 +3109,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.4.0"
|
version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
|
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
|
|
|
@ -14,6 +14,7 @@ actix-cors = "0.5.4"
|
||||||
actix-files = "0.5.0"
|
actix-files = "0.5.0"
|
||||||
actix-session = "0.4.1"
|
actix-session = "0.4.1"
|
||||||
actix-web = "3.3.2"
|
actix-web = "3.3.2"
|
||||||
|
actix-web-httpauth = "0.5.1"
|
||||||
# Used for managing async tasks
|
# Used for managing async tasks
|
||||||
actix-rt = "1.1.1"
|
actix-rt = "1.1.1"
|
||||||
# Used for HTML sanitization
|
# Used for HTML sanitization
|
||||||
|
|
|
@ -67,6 +67,7 @@ Endpoints are similar to Mastodon API:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/accounts/{account_id}
|
GET /api/v1/accounts/{account_id}
|
||||||
|
GET /api/v1/accounts/verify_credentials
|
||||||
PATCH /api/v1/accounts/update_credentials
|
PATCH /api/v1/accounts/update_credentials
|
||||||
GET /api/v1/accounts/relationships
|
GET /api/v1/accounts/relationships
|
||||||
POST /api/v1/accounts/{account_id}/follow
|
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),
|
ipfs_cid VARCHAR(200),
|
||||||
post_id UUID REFERENCES post (id) ON DELETE CASCADE,
|
post_id UUID REFERENCES post (id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
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::directory::views::profile_directory;
|
||||||
use mitra::mastodon_api::instance::views as instance_api;
|
use mitra::mastodon_api::instance::views as instance_api;
|
||||||
use mitra::mastodon_api::media::views::media_api_scope;
|
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::search::views::search;
|
||||||
use mitra::mastodon_api::statuses::views::status_api_scope;
|
use mitra::mastodon_api::statuses::views::status_api_scope;
|
||||||
use mitra::mastodon_api::timelines::views as timeline_api;
|
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(ActixLogger::new("%r : %s : %{r}a"))
|
||||||
.wrap(cors_config)
|
.wrap(cors_config)
|
||||||
.wrap(cookie_config)
|
.wrap(cookie_config)
|
||||||
|
.wrap(create_auth_error_handler())
|
||||||
.data(web::PayloadConfig::default().limit(MAX_UPLOAD_SIZE))
|
.data(web::PayloadConfig::default().limit(MAX_UPLOAD_SIZE))
|
||||||
.data(web::JsonConfig::default().limit(MAX_UPLOAD_SIZE))
|
.data(web::JsonConfig::default().limit(MAX_UPLOAD_SIZE))
|
||||||
.data(config.clone())
|
.data(config.clone())
|
||||||
|
@ -77,6 +80,7 @@ async fn main() -> std::io::Result<()> {
|
||||||
"/contracts",
|
"/contracts",
|
||||||
config.contract_dir.clone(),
|
config.contract_dir.clone(),
|
||||||
))
|
))
|
||||||
|
.service(oauth_api_scope())
|
||||||
.service(user_api::create_user_view)
|
.service(user_api::create_user_view)
|
||||||
.service(user_api::login_view)
|
.service(user_api::login_view)
|
||||||
.service(user_api::current_user_view)
|
.service(user_api::current_user_view)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
use actix_web::{get, post, patch, web, HttpResponse, Scope};
|
use actix_web::{get, post, patch, web, HttpResponse, Scope};
|
||||||
|
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
@ -13,6 +14,7 @@ use crate::config::Config;
|
||||||
use crate::database::{Pool, get_database_client};
|
use crate::database::{Pool, get_database_client};
|
||||||
use crate::errors::HttpError;
|
use crate::errors::HttpError;
|
||||||
use crate::mastodon_api::statuses::types::Status;
|
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::mastodon_api::users::auth::get_current_user;
|
||||||
use crate::models::posts::queries::get_posts_by_author;
|
use crate::models::posts::queries::get_posts_by_author;
|
||||||
use crate::models::profiles::queries::{
|
use crate::models::profiles::queries::{
|
||||||
|
@ -35,6 +37,18 @@ async fn get_account(
|
||||||
Ok(HttpResponse::Ok().json(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")]
|
#[patch("/update_credentials")]
|
||||||
async fn update_credentials(
|
async fn update_credentials(
|
||||||
config: web::Data<Config>,
|
config: web::Data<Config>,
|
||||||
|
@ -198,6 +212,7 @@ pub fn account_api_scope() -> Scope {
|
||||||
web::scope("/api/v1/accounts")
|
web::scope("/api/v1/accounts")
|
||||||
// Routes without account ID
|
// Routes without account ID
|
||||||
.service(get_relationships)
|
.service(get_relationships)
|
||||||
|
.service(verify_credentials)
|
||||||
.service(update_credentials)
|
.service(update_credentials)
|
||||||
// Routes with account ID
|
// Routes with account ID
|
||||||
.service(get_account)
|
.service(get_account)
|
||||||
|
|
|
@ -2,6 +2,7 @@ pub mod accounts;
|
||||||
pub mod directory;
|
pub mod directory;
|
||||||
pub mod instance;
|
pub mod instance;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod oauth;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod statuses;
|
pub mod statuses;
|
||||||
pub mod timelines;
|
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;
|
pub mod attachments;
|
||||||
mod cleanup;
|
mod cleanup;
|
||||||
|
pub mod oauth;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
pub mod relationships;
|
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