Skip to content

Commit 7bc6b60

Browse files
wojcik91Maciej Wójcik
and
Maciej Wójcik
authored
Add API token authentication (#990)
* add basic API token management frontend * handle crud stuff for API tokens * add copy step to add modal * fix typo * handle token auth * handle token auth only if enterprise features are enabled * adjust frontend styling * lint fix * hide API tokens if enterprise features are disabled * update query data * limit API tokens to admin users only * prevent non-admin user token auth * add integration tests --------- Co-authored-by: Maciej Wójcik <[email protected]>
1 parent 052d5ee commit 7bc6b60

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2115
-9
lines changed

.sqlx/query-17bf3525210568070619821a85778c3b2daff9fcb78b7f1a56a5f2555c8a3f79.json

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-3b3c28a99faeeaf24ff0fc5a3592e8010ceb453fbcf1672b23ef086e76c48958.json

+46
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-a9c89bbf3b1a74d9a703bc775255d20ff2c4a6c0806d574c8b398cf94acd38ea.json

+46
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-ca574efcc6bdee6b91f0896cbdf2d92b6410bfcf1067ee9362d4d2679bb78750.json

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-ed5c8bc3290786ed42f4299eca51de51bb5484f85f847c2dc724d10ec1d50d89.json

+46
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-f26d9df16d2217215393244fc509261748beb8f356851b6b7879c972d226c8e2.json

+44
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-fee9434e2efe66928950035aef780f907a064aa46fb0625f9d7b58b373c9a397.json

+25
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.lock

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ serde_cbor = { version = "0.12.0-dev", package = "serde_cbor_2" }
6363
serde_json = "1.0"
6464
serde_urlencoded = "0.7"
6565
sha-1 = "0.10"
66+
sha256 = "1.5"
6667
sqlx = { version = "0.8", features = [
6768
"chrono",
6869
"ipnetwork",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE api_token;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE TABLE api_token (
2+
id bigserial PRIMARY KEY,
3+
user_id bigint NOT NULL,
4+
created_at timestamp without time zone NOT NULL,
5+
name text NOT NULL,
6+
token_hash text NOT NULL,
7+
FOREIGN KEY(user_id) REFERENCES "user"(id) ON DELETE CASCADE
8+
);

src/auth/mod.rs

+57-4
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ use std::{
66
};
77

88
use axum::{
9-
extract::{FromRef, FromRequestParts},
9+
extract::{FromRef, FromRequestParts, OptionalFromRequestParts},
1010
http::{header::AUTHORIZATION, request::Parts},
1111
};
12-
use axum_extra::extract::cookie::CookieJar;
12+
use axum_client_ip::InsecureClientIp;
13+
use axum_extra::{
14+
extract::cookie::CookieJar,
15+
headers::{authorization::Bearer, Authorization},
16+
TypedHeader,
17+
};
1318
use jsonwebtoken::{
1419
decode, encode, errors::Error as JWTError, DecodingKey, EncodingKey, Header, Validation,
1520
};
@@ -21,6 +26,7 @@ use crate::{
2126
models::group::Permission, Group, Id, OAuth2AuthorizedApp, OAuth2Token, Session,
2227
SessionState, User,
2328
},
29+
enterprise::{db::models::api_tokens::ApiToken, is_enterprise_enabled},
2430
error::WebError,
2531
handlers::SESSION_COOKIE_NAME,
2632
};
@@ -126,6 +132,42 @@ where
126132

127133
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
128134
let appstate = AppState::from_ref(state);
135+
136+
// first try to authenticate by API token if one is found in header
137+
if is_enterprise_enabled() {
138+
let maybe_auth_header: Option<TypedHeader<Authorization<Bearer>>> =
139+
<TypedHeader<_> as OptionalFromRequestParts<S>>::from_request_parts(parts, state)
140+
.await
141+
.map_err(|err| {
142+
error!("Failed to extract optional auth header: {err}");
143+
WebError::Authorization("Invalid auth header".into())
144+
})?;
145+
if let Some(header) = maybe_auth_header {
146+
let token_string = header.token();
147+
debug!("Trying to authorize request using API token: {token_string}");
148+
return match ApiToken::try_find_by_auth_token(&appstate.pool, token_string).await {
149+
Ok(Some(api_token)) => {
150+
// create a dummy session and don't store it in the DB
151+
// since each request needs to be authorized anyway
152+
let ip_address = InsecureClientIp::from_request_parts(parts, state)
153+
.await
154+
.map_err(|err| {
155+
error!("Failed to get client IP: {err:?}");
156+
WebError::ClientIpError
157+
})?;
158+
Ok(Session::new(
159+
api_token.user_id,
160+
SessionState::ApiTokenVerified,
161+
ip_address.0.to_string(),
162+
None,
163+
))
164+
}
165+
Ok(None) => Err(WebError::Authorization("Invalid API token".into())),
166+
Err(err) => Err(err.into()),
167+
};
168+
};
169+
}
170+
129171
let Ok(cookies) = CookieJar::from_request_parts(parts, state).await;
130172
if let Some(session_cookie) = cookies.get(SESSION_COOKIE_NAME) {
131173
return {
@@ -183,18 +225,29 @@ where
183225
type Rejection = WebError;
184226

185227
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
186-
let appstate = AppState::from_ref(state);
187228
let session = Session::from_request_parts(parts, state).await?;
229+
let appstate = AppState::from_ref(state);
188230
let user = User::find_by_id(&appstate.pool, session.user_id).await?;
189231

190232
if let Some(user) = user {
191-
if user.mfa_enabled && session.state != SessionState::MultiFactorVerified {
233+
if user.mfa_enabled
234+
&& (session.state != SessionState::MultiFactorVerified
235+
&& session.state != SessionState::ApiTokenVerified)
236+
{
192237
return Err(WebError::Authorization("MFA not verified".into()));
193238
}
194239
let Ok(groups) = user.member_of(&appstate.pool).await else {
195240
return Err(WebError::DbError("cannot fetch groups".into()));
196241
};
197242
let is_admin = user.is_admin(&appstate.pool).await?;
243+
244+
// non-admin users are not allowed to use token auth
245+
if !is_admin && session.state == SessionState::ApiTokenVerified {
246+
return Err(WebError::Forbidden(
247+
"Token authentication is not allowed for normal users".into(),
248+
));
249+
}
250+
198251
Ok(SessionInfo {
199252
session,
200253
user,

0 commit comments

Comments
 (0)