Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add framework for authentication (authkit) and use it for local dev setup & test deployments #645

Merged
merged 17 commits into from
Jan 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .deployment/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
mode: '0755'
with_items:
- tobira
- login-handler.py
- login-handler.js
notify: restart tobira

- name: deploy demo assets
Expand Down
1 change: 1 addition & 0 deletions .deployment/setup-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- python3
- python3-psycopg2
- nginx
- nodejs


# MeiliSearch
Expand Down
2 changes: 1 addition & 1 deletion .deployment/templates/tobiraauth.service
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ After=network.target

[Service]
WorkingDirectory=/opt/tobira/{{ id }}/
ExecStart=/opt/tobira/{{ id }}/login-handler.py /opt/tobira/{{ id }}/socket/auth.sock
ExecStart=node /opt/tobira/{{ id }}/login-handler.js {{ id }}
Restart=always
User=tobira

Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/check-authkit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Authkit & Login-Handler check
JulianKniephoff marked this conversation as resolved.
Show resolved Hide resolved
on:
pull_request:
paths:
- "util/authkit/**"
- "util/dummy-login/**"
- ".github/workflows/check-authkit.yml"

jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v3

- run: npm ci
working-directory: util/authkit
- run: npm ci
working-directory: util/dummy-login

- name: Typecheck/build authkit
working-directory: util/authkit
run: npm run build

- name: Typecheck dummy login handler
working-directory: util/dummy-login
run: npx tsc --noEmit

- name: Make sure dummy login handler build is up to date
working-directory: util/dummy-login
run: |
npm run build
git diff --exit-code dist/index.js


4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
- "docs/docs/setup/config.toml"
- "util/dev-config/*"
- ".deployment/templates/config.toml"
- "util/containers/login-handler.py"
- "util/dummy-login/dist/index.js"
- ".github/workflows/ci.yml"
- ".github/workflows/deploy.yml"
push:
Expand Down Expand Up @@ -135,4 +135,4 @@ jobs:
util/dev-config/favicon.svg
deploy-id
.deployment/templates/config.toml
util/containers/login-handler.py
util/dummy-login/dist/index.js
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
cp -v tmp_artifacts/util/dev-config/logo-large.svg .deployment/files/
cp -v tmp_artifacts/util/dev-config/logo-small.svg .deployment/files/
cp -v tmp_artifacts/util/dev-config/favicon.svg .deployment/files/
cp -v tmp_artifacts/util/containers/login-handler.py .deployment/files/
cp -v tmp_artifacts/util/dummy-login/dist/index.js .deployment/files/login-handler.js

- name: prepare deploy key
env:
Expand Down
10 changes: 5 additions & 5 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ cookie = "0.16"
deadpool = { version = "0.9.0", default-features = false, features = ["managed", "rt_tokio_1"] }
deadpool-postgres = { version = "0.10", default-features = false, features = ["rt_tokio_1"] }
elliptic-curve = { version = "0.12.0", features = ["jwk", "sec1"] }
form_urlencoded = "1.1.0"
futures = { version = "0.3.1", default-features = false, features = ["std"] }
hex = "0.4.3"
hostname = "0.3"
Expand Down
174 changes: 152 additions & 22 deletions backend/src/auth/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
use hyper::{Body, StatusCode};

use crate::{db, http::{self, Context, Request, Response}, prelude::*};
use serde::Deserialize;

use crate::{
db,
http::{self, Context, Request, Response, response::{bad_request, internal_server_error}},
prelude::*,
config::OpencastConfig, auth::ROLE_ANONYMOUS,
};
use super::{AuthMode, SessionId, User};


/// Handles POST requests to `/~session`.
pub(crate) async fn handle_login(req: Request<Body>, ctx: &Context) -> Result<Response, Response> {
pub(crate) async fn handle_post_session(req: Request<Body>, ctx: &Context) -> Result<Response, Response> {
if ctx.config.auth.mode != AuthMode::LoginProxy {
warn!("Got POST /~session request, but due to the authentication mode, this endpoint \
is disabled");
Expand All @@ -24,23 +30,7 @@ pub(crate) async fn handle_login(req: Request<Body>, ctx: &Context) -> Result<Re
// need to create a DB session now and reply with a `set-cookie` header.
debug!("Login request for '{}' (POST '/~session' with auth headers)", user.username);

// TODO: check if a user is already logged in? And remove that session then?

let db = db::get_conn_or_service_unavailable(&ctx.db_pool).await?;
let session_id = user.persist_new_session(&db).await.map_err(|e| {
error!("DB query failed when adding new user session: {}", e);
http::response::internal_server_error()
})?;
debug!("Persisted new session for '{}'", user.username);

Response::builder()
.status(StatusCode::NO_CONTENT)
.header("set-cookie", session_id.set_cookie(
ctx.config.auth.session_duration
).to_string())
.body(Body::empty())
.unwrap()
.pipe(Ok)
create_session(user, ctx).await
}

None => {
Expand All @@ -67,8 +57,8 @@ pub(crate) async fn handle_login(req: Request<Body>, ctx: &Context) -> Result<Re
/// settings. That's the proper tool to remove sessions. Still:
///
/// TODO: maybe notify the user about these failures?
pub(crate) async fn handle_logout(req: Request<Body>, ctx: &Context) -> Response {
if ctx.config.auth.mode != AuthMode::LoginProxy {
pub(crate) async fn handle_delete_session(req: Request<Body>, ctx: &Context) -> Response {
if !matches!(ctx.config.auth.mode, AuthMode::LoginProxy | AuthMode::Opencast) {
warn!("Got DELETE /~session request, but due to the authentication mode, this endpoint \
is disabled");

Expand Down Expand Up @@ -106,3 +96,143 @@ pub(crate) async fn handle_logout(req: Request<Body>, ctx: &Context) -> Response

response
}

const USERID_FIELD: &str = "userid";
const PASSWORD_FIELD: &str = "password";

/// Handles `POST /~login` request.
pub(crate) async fn handle_post_login(req: Request<Body>, ctx: &Context) -> Response {
if ctx.config.auth.mode != AuthMode::Opencast {
warn!("Got POST /~login request, but 'auth.mode' is not 'opencast', \
so login requests have to be handled by your reverse proxy. \
Please see the documentation about auth.");
return Response::builder().status(StatusCode::NOT_FOUND).body(Body::empty()).unwrap();
}

trace!("Handling POST /~login...");

// Make sure the request has the right content type.
let correct_content_type = req.headers()
.get(hyper::header::CONTENT_TYPE)
.map_or(false, |v| v.as_bytes().starts_with(b"application/x-www-form-urlencoded"));
if !correct_content_type {
return bad_request("incorrect content type");
}

// Download whole body.
let body = match hyper::body::to_bytes(req.into_body()).await {
Ok(v) => v,
Err(e) => {
error!("Failed to download login request body: {e}");
return bad_request(None);
},
};

// Extract form data.
let mut userid = None;
let mut password = None;
for (key, value) in form_urlencoded::parse(&body) {
match key.as_ref() {
USERID_FIELD if userid.is_some() => return bad_request("duplicate field userid"),
USERID_FIELD => userid = Some(value),
PASSWORD_FIELD if password.is_some() => return bad_request("duplicate field password"),
PASSWORD_FIELD => password = Some(value),
_ => return bad_request("unknown field"),
}
}

let Some(userid) = userid else {
return bad_request("missing field userid");
};
let Some(password) = password else {
return bad_request("missing field password");
};


// Check the login data.
match check_opencast_login(&userid, &password, &ctx.config.opencast).await {
Err(e) => {
error!("Error occured while checking Opencast login data: {e}");
internal_server_error()
}
Ok(None) => Response::builder().status(StatusCode::FORBIDDEN).body(Body::empty()).unwrap(),
Ok(Some(user)) => create_session(user, ctx).await.unwrap_or_else(|e| e),
}
}

async fn check_opencast_login(
userid: &str,
password: &str,
config: &OpencastConfig,
) -> Result<Option<User>> {
trace!("Checking Opencast login...");
let client = crate::util::http_client();

// Send request. We use basic auth here: our configuration checks already
// assert that we use HTTPS or Opencast is running on the same machine
// (or the admin has explicitly opted out of this check).
let auth_header = format!("Basic {}", base64::encode(&format!("{userid}:{password}")));
let req = Request::builder()
.uri(config.sync_node().clone().with_path_and_query("/info/me.json"))
.header(hyper::header::AUTHORIZATION, auth_header)
.body(Body::empty())
.unwrap();
let response = client.request(req).await?;


// We treat all non-OK response as invalid login data.
if response.status() != StatusCode::OK {
return Ok(None);
}


// Deserialize JSON body.
#[derive(Deserialize)]
struct InfoMeResponse {
roles: Vec<String>,
user: InfoMeUserResponse,
}

#[derive(Deserialize)]
struct InfoMeUserResponse {
name: String,
username: String,
}

let body = hyper::body::to_bytes(response.into_body()).await?;
let info: InfoMeResponse = serde_json::from_slice(&body)
.context("Could not deserialize `/info/me.json` response")?;

// If all roles are `ROLE_ANONYMOUS`, then we assume the login was invalid.
if info.roles.iter().all(|role| role == super::ROLE_ANONYMOUS) {
return Ok(None);
}

// Otherwise the login was correct!
Ok(Some(User {
username: info.user.username,
display_name: info.user.name,
// Sometimes, Opencast does not include `ROLE_ANONYMOUS` in the
// response, so we just add it here to be sure.
roles: info.roles.into_iter().chain([ROLE_ANONYMOUS.to_owned()]).collect(),
}))
}

/// Creates a session for the given user and persists it in the DB.
async fn create_session(user: User, ctx: &Context) -> Result<Response, Response> {
// TODO: check if a user is already logged in? And remove that session then?

let db = db::get_conn_or_service_unavailable(&ctx.db_pool).await?;
let session_id = user.persist_new_session(&db).await.map_err(|e| {
error!("DB query failed when adding new user session: {}", e);
http::response::internal_server_error()
})?;
debug!("Persisted new session for '{}'", user.username);

Response::builder()
.status(StatusCode::NO_CONTENT)
.header("set-cookie", session_id.set_cookie(ctx.config.auth.session_duration).to_string())
.body(Body::empty())
.unwrap()
.pipe(Ok)
}
11 changes: 7 additions & 4 deletions backend/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ mod jwt;
pub(crate) use self::{
session_id::SessionId,
jwt::{JwtConfig, JwtContext},
handlers::{handle_login, handle_logout},
handlers::{handle_post_session, handle_delete_session, handle_post_login},
};


Expand Down Expand Up @@ -138,6 +138,7 @@ pub(crate) enum AuthMode {
None,
FullAuthProxy,
LoginProxy,
Opencast,
}

/// Information about whether or not, and if so how
Expand Down Expand Up @@ -200,9 +201,11 @@ impl User {
match auth_config.mode {
AuthMode::None => Ok(None),
AuthMode::FullAuthProxy => Ok(Self::from_auth_headers(headers, auth_config).into()),
AuthMode::LoginProxy => Self::from_session(headers, db, auth_config.session_duration)
.await
.map(Into::into),
AuthMode::LoginProxy | AuthMode::Opencast => {
Self::from_session(headers, db, auth_config.session_duration)
.await
.map(Into::into)
}
}
}

Expand Down
20 changes: 7 additions & 13 deletions backend/src/http/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,24 @@ pub(super) async fn handle(req: Request<Body>, ctx: Arc<Context>) -> Response {
register_req!(HttpReqCategory::GraphQL);
handle_api(req, &ctx).await.unwrap_or_else(|r| r)
},
"/~login" if method == Method::POST => {
register_req!(HttpReqCategory::Login);
auth::handle_post_login(req, &ctx).await
},
"/~session" if method == Method::POST => {
register_req!(HttpReqCategory::Login);
auth::handle_login(req, &ctx).await.unwrap_or_else(|r| r)
auth::handle_post_session(req, &ctx).await.unwrap_or_else(|r| r)
},
"/~session" if method == Method::DELETE => {
register_req!(HttpReqCategory::Logout);
auth::handle_logout(req, &ctx).await
auth::handle_delete_session(req, &ctx).await
},

// From this point on, we only support GET and HEAD requests. All others
// will result in 404.
_ if method != Method::GET && method != Method::HEAD => {
register_req!(HttpReqCategory::Other);

// Do some helpful logging
let note = if path == "/~login" {
" (You have to configure your reverse proxy to handle login \
requests for you! These should never arrive at Tobira. Please \
see the docs about auth.)"
} else {
""
};
debug!("Responding 405 Method not allowed to {method:?} {path} {note}");

debug!("Responding 405 Method not allowed to {method:?} {path}");
Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header("Content-Type", "text/plain; charset=UTF-8")
Expand Down
Loading