diff --git a/Cargo.lock b/Cargo.lock index 7553a58..9275487 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,6 +448,17 @@ dependencies = [ "byte-tools", ] +[[package]] +name = "branca" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e85a179a6f9595d798a16a97ce64312beb20cfe30ebc40f94271a5fda89f3f" +dependencies = [ + "base-x", + "byteorder", + "orion", +] + [[package]] name = "brotli-sys" version = "0.3.2" @@ -1039,6 +1050,17 @@ dependencies = [ "wasi 0.9.0+wasi-snapshot-preview1", ] +[[package]] +name = "getrandom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8025cf36f917e6a52cce185b7c7177689b838b7ec138364e50cc2277a56cf4" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "gif" version = "0.11.1" @@ -1644,6 +1666,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afbb993947f111397c2bc536944f8dac7f54a4e73383d478efe1990b56404b60" +[[package]] +name = "orion" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1066cd107e316d26fe9f8657525afc01ed74b3288126ee7e58b5b141916aab09" +dependencies = [ + "base64 0.13.0", + "getrandom 0.2.0", + "subtle", + "zeroize", +] + [[package]] name = "parking_lot" version = "0.11.1" @@ -1776,6 +1810,7 @@ dependencies = [ "anyhow", "ape", "base64 0.13.0", + "branca", "cookie", "crossbeam-channel", "diesel", @@ -1902,7 +1937,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" dependencies = [ - "getrandom", + "getrandom 0.1.15", "libc", "rand_chacha", "rand_core", @@ -1925,7 +1960,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" dependencies = [ - "getrandom", + "getrandom 0.1.15", ] [[package]] @@ -2904,3 +2939,9 @@ dependencies = [ "winapi 0.2.8", "winapi-build", ] + +[[package]] +name = "zeroize" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a974bcdd357f0dca4d41677db03436324d45a4c9ed2d0b873a5a360ce41c36" diff --git a/Cargo.toml b/Cargo.toml index c14ed3d..51e0d34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ actix-web-httpauth = { version = "0.5.0" } anyhow = "1.0.35" ape = "0.3.0" base64 = "0.13" +branca = "0.10.0" cookie = { version = "0.14", features = ["signed", "key-expansion"] } crossbeam-channel = "0.5" diesel_migrations = { version = "1.4", features = ["sqlite"] } diff --git a/docs/swagger/polaris-api.json b/docs/swagger/polaris-api.json index 75028e2..cfc2964 100644 --- a/docs/swagger/polaris-api.json +++ b/docs/swagger/polaris-api.json @@ -93,7 +93,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -123,7 +125,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -151,7 +155,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -180,7 +186,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -207,7 +215,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -235,7 +245,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -265,7 +277,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -295,7 +309,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -335,7 +351,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -363,7 +381,9 @@ }, "security": [ { - "admin_http_header": [], + "admin_http_basic": [], + "admin_http_bearer": [], + "admin_query_parameter": [], "admin_cookie": [] } ] @@ -390,7 +410,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -418,7 +440,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -436,14 +460,21 @@ "content": { "application/json": { "schema": { - "$ref": "#components/schemas/AuthCredentials" + "$ref": "#components/schemas/Credentials" } } } }, "responses": { "200": { - "description": "Successful operation" + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Authorization" + } + } + } }, "401": { "description": "Invalid credentials" @@ -475,7 +506,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -515,7 +548,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -545,7 +580,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -585,7 +622,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -615,7 +654,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -645,7 +686,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -685,7 +728,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -722,7 +767,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -768,7 +815,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -798,7 +847,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -838,7 +889,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -876,7 +929,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -904,7 +959,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -934,7 +991,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -964,7 +1023,38 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], + "auth_cookie": [] + } + ] + } + }, + "/lastfm/link_token": { + "get": { + "tags": [ + "Last.fm" + ], + "summary": "Obtain an authentication token to be used when linking a Polaris account to a Last.fm account. The token is only valid for 10 minutes.", + "operationId": "getLastFMLinkToken", + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LastFMLinkToken" + } + } + } + } + }, + "security": [ + { + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -982,6 +1072,15 @@ }, "operationId": "getLastFMLink", "parameters": [ + { + "name": "auth_token", + "in": "query", + "required": true, + "description": "Polaris authentication token received from the `lastfm/link_token` endpoint", + "schema": { + "type": "string" + } + }, { "name": "token", "in": "query", @@ -1025,7 +1124,9 @@ }, "security": [ { - "auth_http_header": [], + "auth_http_basic": [], + "auth_http_bearer": [], + "auth_query_parameter": [], "auth_cookie": [] } ] @@ -1183,7 +1284,7 @@ } } }, - "AuthCredentials": { + "Credentials": { "type": "object", "properties": { "username": { @@ -1194,6 +1295,28 @@ } } }, + "Authorization": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "token": { + "type": "string" + }, + "is_admin": { + "type": "bool" + } + } + }, + "LastFMLinkToken": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + }, "CollectionFile": { "oneOf": [ { @@ -1320,26 +1443,49 @@ } }, "securitySchemes": { - "auth_http_header": { + "auth_http_bearer": { "type": "http", - "scheme": "basic" + "scheme": "bearer", + "description": "An authentication token obtained in the output of the `auth` endpoint" + }, + "admin_http_bearer": { + "type": "http", + "scheme": "bearer", + "description": "Identical to the auth_http_bearer scheme but only for users recognized as admin by the Polaris server" + }, + "auth_query_parameter": { + "type": "apikey", + "in": "query", + "name": "auth_token", + "description": "An authentication token obtained in the output of the `auth` endpoint" + }, + "admin_query_parameter": { + "type": "apikey", + "in": "query", + "name": "auth_token", + "description": "Identical to the auth_query_parameter scheme but only for users recognized as admin by the Polaris server" + }, + "auth_http_basic": { + "type": "http", + "scheme": "basic", + "description": "[deprecated]" + }, + "admin_http_basic": { + "type": "http", + "scheme": "basic", + "description": "[deprecated] Identical to the auth_http_basic scheme but only for users recognized as admin by the Polaris server" }, "auth_cookie": { "type": "apikey", "in": "cookie", "name": "session", - "description": "A session token obtained returned as a server cookie by making a request via the auth_http_header scheme." - }, - "admin_http_header": { - "type": "http", - "scheme": "basic", - "description": "Identical to the auth_http_header scheme but only for users recognized as admin by the Polaris server" + "description": "[deprecated] A token obtained via the SET-COOKIE header in a response to a request via the auth_http_basic scheme, or a request to the `auth` endpoint." }, "admin_cookie": { "type": "apikey", "in": "cookie", "name": "session", - "description": "Identical to the auth_cookie scheme but only for users recognized as admin by the Polaris server" + "description": "[deprecated] Identical to the auth_cookie scheme but only for users recognized as admin by the Polaris server" } }, "links": {}, diff --git a/src/app/config/test.rs b/src/app/config/test.rs index d8cc155..f885cb0 100644 --- a/src/app/config/test.rs +++ b/src/app/config/test.rs @@ -24,7 +24,8 @@ fn get_test_db(name: &str) -> DB { fn apply_saves_misc_settings() { let db = get_test_db(&test_name!()); let settings_manager = settings::Manager::new(db.clone()); - let user_manager = user::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = user::Manager::new(db.clone(), auth_secret); let vfs_manager = vfs::Manager::new(db.clone()); let ddns_manager = ddns::Manager::new(db.clone()); let config_manager = Manager::new( @@ -60,7 +61,8 @@ fn apply_saves_misc_settings() { fn apply_saves_mount_points() { let db = get_test_db(&test_name!()); let settings_manager = settings::Manager::new(db.clone()); - let user_manager = user::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = user::Manager::new(db.clone(), auth_secret); let vfs_manager = vfs::Manager::new(db.clone()); let ddns_manager = ddns::Manager::new(db.clone()); let config_manager = Manager::new( @@ -89,7 +91,8 @@ fn apply_saves_ddns_settings() { let db = get_test_db(&test_name!()); let settings_manager = settings::Manager::new(db.clone()); - let user_manager = user::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = user::Manager::new(db.clone(), auth_secret); let vfs_manager = vfs::Manager::new(db.clone()); let ddns_manager = ddns::Manager::new(db.clone()); let config_manager = Manager::new( @@ -117,7 +120,8 @@ fn apply_saves_ddns_settings() { fn apply_can_toggle_admin() { let db = get_test_db(&test_name!()); let settings_manager = settings::Manager::new(db.clone()); - let user_manager = user::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = user::Manager::new(db.clone(), auth_secret); let vfs_manager = vfs::Manager::new(db.clone()); let ddns_manager = ddns::Manager::new(db.clone()); let config_manager = Manager::new( diff --git a/src/app/lastfm/manager.rs b/src/app/lastfm/manager.rs index 18a7a56..843f021 100644 --- a/src/app/lastfm/manager.rs +++ b/src/app/lastfm/manager.rs @@ -2,6 +2,7 @@ use anyhow::*; use rustfm_scrobble::{Scrobble, Scrobbler}; use serde::Deserialize; use std::path::Path; +use user::AuthToken; use crate::app::{index::Index, user}; @@ -53,12 +54,19 @@ impl Manager { } } - pub fn link(&self, username: &str, token: &str) -> Result<()> { + pub fn generate_link_token(&self, username: &str) -> Result { + self.user_manager + .generate_lastfm_link_token(username) + .map_err(|e| e.into()) + } + + pub fn link(&self, username: &str, lastfm_token: &str) -> Result<()> { let mut scrobbler = Scrobbler::new(LASTFM_API_KEY.into(), LASTFM_API_SECRET.into()); - let auth_response = scrobbler.authenticate_with_token(token)?; + let auth_response = scrobbler.authenticate_with_token(lastfm_token)?; self.user_manager .lastfm_link(username, &auth_response.name, &auth_response.key) + .map_err(|e| e.into()) } pub fn unlink(&self, username: &str) -> Result<()> { diff --git a/src/app/settings/mod.rs b/src/app/settings/mod.rs index d658a93..d0105e9 100644 --- a/src/app/settings/mod.rs +++ b/src/app/settings/mod.rs @@ -6,7 +6,7 @@ mod manager; pub use error::*; pub use manager::*; -#[derive(Clone)] +#[derive(Clone, Default)] pub struct AuthSecret { pub key: [u8; 32], } diff --git a/src/app/user/error.rs b/src/app/user/error.rs index a6d01ed..bb8c082 100644 --- a/src/app/user/error.rs +++ b/src/app/user/error.rs @@ -6,6 +6,12 @@ pub enum Error { EmptyPassword, #[error("Username does not exist")] IncorrectUsername, + #[error("Password does not match username")] + IncorrectPassword, + #[error("Invalid auth token")] + InvalidAuthToken, + #[error("Incorrect authorization scope")] + IncorrectAuthorizationScope, #[error("Unspecified")] Unspecified, } diff --git a/src/app/user/manager.rs b/src/app/user/manager.rs index 0e5a754..ad4c56e 100644 --- a/src/app/user/manager.rs +++ b/src/app/user/manager.rs @@ -1,20 +1,24 @@ use anyhow::anyhow; use diesel; use diesel::prelude::*; +use std::time::{SystemTime, UNIX_EPOCH}; use super::*; +use crate::app::settings::AuthSecret; use crate::db::DB; const HASH_ITERATIONS: u32 = 10000; #[derive(Clone)] pub struct Manager { + // TODO make this private and move preferences methods in this file pub db: DB, + auth_secret: AuthSecret, } impl Manager { - pub fn new(db: DB) -> Self { - Self { db } + pub fn new(db: DB, auth_secret: AuthSecret) -> Self { + Self { db, auth_secret } } pub fn create(&self, new_user: &NewUser) -> Result<(), Error> { @@ -67,7 +71,7 @@ impl Manager { Ok(()) } - pub fn login(&self, username: &str, password: &str) -> Result { + pub fn login(&self, username: &str, password: &str) -> Result { use crate::db::users::dsl::*; let connection = self.db.connect()?; match users @@ -78,11 +82,68 @@ impl Manager { Err(diesel::result::Error::NotFound) => Err(Error::IncorrectUsername), Ok(hash) => { let hash: String = hash; - Ok(verify_password(&hash, password)) + if verify_password(&hash, password) { + let authorization = Authorization { + username: username.to_owned(), + scope: AuthorizationScope::PolarisAuth, + }; + self.generate_auth_token(&authorization) + } else { + Err(Error::IncorrectPassword) + } } Err(_) => Err(Error::Unspecified), } } + + pub fn authenticate( + &self, + auth_token: &AuthToken, + scope: AuthorizationScope, + ) -> Result { + let authorization = self.decode_auth_token(auth_token, scope)?; + if self.exists(&authorization.username)? { + Ok(authorization) + } else { + Err(Error::IncorrectUsername) + } + } + + fn decode_auth_token( + &self, + auth_token: &AuthToken, + scope: AuthorizationScope, + ) -> Result { + let AuthToken(data) = auth_token; + let ttl = match scope { + AuthorizationScope::PolarisAuth => 0, // permanent + AuthorizationScope::LastFMLink => 10 * 60, // 10 minutes + }; + let authorization = branca::decode(data, &self.auth_secret.key, ttl) + .map_err(|_| Error::InvalidAuthToken)?; + let authorization: Authorization = + serde_json::from_slice(&authorization[..]).map_err(|_| Error::InvalidAuthToken)?; + if authorization.scope != scope { + return Err(Error::IncorrectAuthorizationScope); + } + Ok(authorization) + } + + fn generate_auth_token(&self, authorization: &Authorization) -> Result { + let serialized_authorization = + serde_json::to_string(&authorization).map_err(|_| Error::Unspecified)?; + branca::encode( + serialized_authorization.as_bytes(), + &self.auth_secret.key, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Unspecified)? + .as_secs() as u32, + ) + .map_err(|_| Error::Unspecified) + .map(AuthToken) + } + pub fn count(&self) -> anyhow::Result { use crate::db::users::dsl::*; let connection = self.db.connect()?; @@ -110,13 +171,14 @@ impl Manager { Ok(results.len() > 0) } - pub fn is_admin(&self, username: &str) -> anyhow::Result { + pub fn is_admin(&self, username: &str) -> Result { use crate::db::users::dsl::*; let connection = self.db.connect()?; let is_admin: i32 = users .filter(name.eq(username)) .select(admin) - .get_result(&connection)?; + .get_result(&connection) + .map_err(|_| Error::Unspecified)?; Ok(is_admin != 0) } @@ -125,7 +187,7 @@ impl Manager { username: &str, lastfm_login: &str, session_key: &str, - ) -> anyhow::Result<()> { + ) -> Result<(), Error> { use crate::db::users::dsl::*; let connection = self.db.connect()?; diesel::update(users.filter(name.eq(username))) @@ -133,10 +195,18 @@ impl Manager { lastfm_username.eq(lastfm_login), lastfm_session_key.eq(session_key), )) - .execute(&connection)?; + .execute(&connection) + .map_err(|_| Error::Unspecified)?; Ok(()) } + pub fn generate_lastfm_link_token(&self, username: &str) -> Result { + self.generate_auth_token(&Authorization { + username: username.to_owned(), + scope: AuthorizationScope::LastFMLink, + }) + } + pub fn get_lastfm_session_key(&self, username: &str) -> anyhow::Result { use crate::db::users::dsl::*; let connection = self.db.connect()?; diff --git a/src/app/user/mod.rs b/src/app/user/mod.rs index c2ded56..b3667c9 100644 --- a/src/app/user/mod.rs +++ b/src/app/user/mod.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::db::users; @@ -32,3 +32,18 @@ pub struct NewUser { pub password: String, pub admin: bool, } + +#[derive(Debug)] +pub struct AuthToken(pub String); + +#[derive(Debug, PartialEq, Deserialize, Serialize)] +pub enum AuthorizationScope { + PolarisAuth, + LastFMLink, +} + +#[derive(Debug, PartialEq, Deserialize, Serialize)] +pub struct Authorization { + pub username: String, + pub scope: AuthorizationScope, +} diff --git a/src/app/user/test.rs b/src/app/user/test.rs index 3e238e4..86a9898 100644 --- a/src/app/user/test.rs +++ b/src/app/user/test.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app::settings; use crate::db::DB; use crate::test_name; @@ -19,7 +20,9 @@ pub fn get_test_db(name: &str) -> DB { #[test] fn create_delete_user_golden_path() { let db = get_test_db(&test_name!()); - let user_manager = Manager::new(db); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); let new_user = NewUser { name: "Walter".to_owned(), @@ -37,7 +40,9 @@ fn create_delete_user_golden_path() { #[test] fn cannot_create_user_with_blank_username() { let db = get_test_db(&test_name!()); - let user_manager = Manager::new(db); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); let new_user = NewUser { name: "".to_owned(), @@ -54,7 +59,9 @@ fn cannot_create_user_with_blank_username() { #[test] fn cannot_create_user_with_blank_password() { let db = get_test_db(&test_name!()); - let user_manager = Manager::new(db); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); let new_user = NewUser { name: "Walter".to_owned(), @@ -71,7 +78,9 @@ fn cannot_create_user_with_blank_password() { #[test] fn cannot_create_duplicate_user() { let db = get_test_db(&test_name!()); - let user_manager = Manager::new(db); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); let new_user = NewUser { name: "Walter".to_owned(), @@ -86,7 +95,9 @@ fn cannot_create_duplicate_user() { #[test] fn can_read_write_preferences() { let db = get_test_db(&test_name!()); - let user_manager = Manager::new(db); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); let new_preferences = Preferences { web_theme_base: Some("very-dark-theme".to_owned()), @@ -108,3 +119,126 @@ fn can_read_write_preferences() { let read_preferences = user_manager.read_preferences("Walter").unwrap(); assert_eq!(new_preferences, read_preferences); } + +#[test] +fn login_rejects_bad_password() { + let db = get_test_db(&test_name!()); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); + + let username = "Walter"; + let password = "super_secret!"; + + let new_user = NewUser { + name: username.to_owned(), + password: password.to_owned(), + admin: false, + }; + + user_manager.create(&new_user).unwrap(); + assert_eq!( + user_manager + .login(username, "not the password") + .unwrap_err(), + Error::IncorrectPassword + ) +} + +#[test] +fn login_golden_path() { + let db = get_test_db(&test_name!()); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); + + let username = "Walter"; + let password = "super_secret!"; + + let new_user = NewUser { + name: username.to_owned(), + password: password.to_owned(), + admin: false, + }; + + user_manager.create(&new_user).unwrap(); + assert!(user_manager.login(username, password).is_ok()) +} + +#[test] +fn authenticate_rejects_bad_token() { + let db = get_test_db(&test_name!()); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); + + let username = "Walter"; + let password = "super_secret!"; + + let new_user = NewUser { + name: username.to_owned(), + password: password.to_owned(), + admin: false, + }; + + user_manager.create(&new_user).unwrap(); + let fake_token = AuthToken("fake token".to_owned()); + assert!(user_manager + .authenticate(&fake_token, AuthorizationScope::PolarisAuth) + .is_err()) +} + +#[test] +fn authenticate_golden_path() { + let db = get_test_db(&test_name!()); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); + + let username = "Walter"; + let password = "super_secret!"; + + let new_user = NewUser { + name: username.to_owned(), + password: password.to_owned(), + admin: false, + }; + + user_manager.create(&new_user).unwrap(); + let token = user_manager.login(username, password).unwrap(); + let authorization = user_manager + .authenticate(&token, AuthorizationScope::PolarisAuth) + .unwrap(); + assert_eq!( + authorization, + Authorization { + username: username.to_owned(), + scope: AuthorizationScope::PolarisAuth, + } + ) +} + +#[test] +fn authenticate_validates_scope() { + let db = get_test_db(&test_name!()); + let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = Manager::new(db, auth_secret); + + let username = "Walter"; + let password = "super_secret!"; + + let new_user = NewUser { + name: username.to_owned(), + password: password.to_owned(), + admin: false, + }; + + user_manager.create(&new_user).unwrap(); + let token = user_manager.generate_lastfm_link_token(username).unwrap(); + let authorization = user_manager.authenticate(&token, AuthorizationScope::PolarisAuth); + assert_eq!( + authorization.unwrap_err(), + Error::IncorrectAuthorizationScope + ) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 885e995..d392cf7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -102,7 +102,8 @@ pub fn get_test_db(name: &str) -> DB { let db = DB::new(&db_path).unwrap(); let settings_manager = settings::Manager::new(db.clone()); - let user_manager = user::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret().unwrap(); + let user_manager = user::Manager::new(db.clone(), auth_secret); let vfs_manager = vfs::Manager::new(db.clone()); let ddns_manager = ddns::Manager::new(db.clone()); let config_manager = diff --git a/src/service/actix/api.rs b/src/service/actix/api.rs index 514dd85..7097546 100644 --- a/src/service/actix/api.rs +++ b/src/service/actix/api.rs @@ -10,7 +10,7 @@ use actix_web::{ web::{self, Data, Json, JsonConfig, ServiceConfig}, FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError, }; -use actix_web_httpauth::extractors::basic::BasicAuth; +use actix_web_httpauth::extractors::{basic::BasicAuth, bearer::BearerAuth}; use cookie::{self, *}; use futures_util::future::{err, ok}; use percent_encoding::percent_decode_str; @@ -64,6 +64,7 @@ pub fn make_config() -> impl FnOnce(&mut ServiceConfig) + Clone { .service(delete_playlist) .service(lastfm_now_playing) .service(lastfm_scrobble) + .service(lastfm_link_token) .service(lastfm_link) .service(lastfm_unlink); } @@ -151,8 +152,10 @@ impl FromRequest for Cookies { #[derive(Debug)] enum AuthSource { - AuthorizationHeader, + AuthorizationBasic, + AuthorizationBearer, Cookie, + QueryParameter, } #[derive(Debug)] @@ -173,7 +176,10 @@ impl FromRequest for Auth { }; let cookies_future = Cookies::from_request(request, payload); - let http_auth_future = BasicAuth::from_request(request, payload); + let basic_auth_future = BasicAuth::from_request(request, payload); + let bearer_auth_future = BearerAuth::from_request(request, payload); + let query_params_future = + web::Query::::from_request(request, payload); Box::pin(async move { // Auth via session cookie @@ -192,20 +198,46 @@ impl FromRequest for Auth { } } - // Auth via HTTP header + // Auth via bearer token in query parameter + if let Ok(query) = query_params_future.await { + let auth_token = user::AuthToken(query.auth_token.clone()); + let authorization = block(move || { + user_manager.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth) + }) + .await?; + return Ok(Auth { + username: authorization.username.to_owned(), + source: AuthSource::QueryParameter, + }); + } + + // Auth via bearer token in authorization header + if let Ok(bearer_auth) = bearer_auth_future.await { + let auth_token = user::AuthToken(bearer_auth.token().to_owned()); + let authorization = block(move || { + user_manager.authenticate(&auth_token, user::AuthorizationScope::PolarisAuth) + }) + .await?; + return Ok(Auth { + username: authorization.username.to_owned(), + source: AuthSource::AuthorizationBearer, + }); + } + + // Auth via basic authorization header { - let auth = http_auth_future.await?; - let username = auth.user_id().to_string(); - let password = auth + let basic_auth = basic_auth_future.await?; + let username = basic_auth.user_id().to_string(); + let password = basic_auth .password() .map(|s| s.as_ref()) .unwrap_or("") .to_string(); - let auth_result = block(move || user_manager.login(&username, &password)).await?; - if auth_result { + let auth_result = block(move || user_manager.login(&username, &password)).await; + if auth_result.is_ok() { Ok(Auth { - username: auth.user_id().to_string(), - source: AuthSource::AuthorizationHeader, + username: basic_auth.user_id().to_string(), + source: AuthSource::AuthorizationBasic, }) } else { Err(ErrorUnauthorized(APIError::Unspecified)) @@ -280,8 +312,10 @@ pub fn http_auth_middleware< let mut response = response_future.await?; if let Ok(auth) = auth_future.await { let set_cookies = match auth.source { - AuthSource::AuthorizationHeader => true, + AuthSource::AuthorizationBasic => true, + AuthSource::AuthorizationBearer => false, AuthSource::Cookie => false, + AuthSource::QueryParameter => false, }; if set_cookies { let cookies = cookies_future.await?; @@ -547,20 +581,23 @@ async fn trigger_index( #[post("/auth")] async fn login( user_manager: Data, - credentials: Json, + credentials: Json, cookies: Cookies, ) -> Result { let username = credentials.username.clone(); - let is_admin = block(move || { - if !user_manager.login(&credentials.username, &credentials.password)? { - return Err(APIError::IncorrectCredentials); - } - user_manager - .is_admin(&credentials.username) - .map_err(|_| APIError::Unspecified) - }) - .await?; - let mut response = HttpResponse::Ok().finish(); + let (user::AuthToken(token), is_admin) = + block(move || -> Result<(user::AuthToken, bool), APIError> { + let auth_token = user_manager.login(&credentials.username, &credentials.password)?; + let is_admin = user_manager.is_admin(&credentials.username)?; + Ok((auth_token, is_admin)) + }) + .await?; + let authorization = dto::Authorization { + username: username.clone(), + token, + is_admin, + }; + let mut response = HttpResponse::Ok().json(authorization); add_auth_cookies(&mut response, &cookies, &username, is_admin) .map_err(|_| APIError::Unspecified)?; Ok(response) @@ -770,14 +807,29 @@ async fn lastfm_scrobble( Ok(HttpResponse::new(StatusCode::OK)) } +#[get("/lastfm/link_token")] +async fn lastfm_link_token( + lastfm_manager: Data, + auth: Auth, +) -> Result, APIError> { + let user::AuthToken(value) = + block(move || lastfm_manager.generate_link_token(&auth.username)).await?; + Ok(Json(dto::LastFMLinkToken { value })) +} + #[get("/lastfm/link")] async fn lastfm_link( lastfm_manager: Data, - auth: Auth, + user_manager: Data, payload: web::Query, ) -> Result { let popup_content_string = block(move || { - lastfm_manager.link(&auth.username, &payload.token)?; + let auth_token = user::AuthToken(payload.auth_token.clone()); + let authorization = + user_manager.authenticate(&auth_token, user::AuthorizationScope::LastFMLink)?; + let lastfm_token = &payload.token; + lastfm_manager.link(&authorization.username, lastfm_token)?; + // Percent decode let base64_content = percent_decode_str(&payload.content).decode_utf8_lossy(); diff --git a/src/service/actix/test.rs b/src/service/actix/test.rs index 698e771..1bc9343 100644 --- a/src/service/actix/test.rs +++ b/src/service/actix/test.rs @@ -1,5 +1,4 @@ use actix_web::{ - client::ClientResponse, middleware::{Compress, Logger}, rt::{System, SystemRunner}, test, @@ -7,36 +6,26 @@ use actix_web::{ web::Bytes, App, }; -use cookie::Cookie; -use http::{header, response::Builder, Method, Request, Response}; +use http::{response::Builder, Method, Request, Response}; use serde::de::DeserializeOwned; use serde::Serialize; -use std::collections::HashMap; use std::fs; use std::ops::Deref; use std::path::{Path, PathBuf}; use crate::service::actix::*; +use crate::service::dto; use crate::service::test::TestService; pub struct ActixTestService { system_runner: SystemRunner, - cookies: HashMap, + authorization: Option, server: TestServer, } pub type ServiceType = ActixTestService; impl ActixTestService { - fn update_cookies(&mut self, actix_response: &ClientResponse) { - let cookies = actix_response.headers().get_all(header::SET_COOKIE); - for raw_cookie in cookies { - let cookie = Cookie::parse(raw_cookie.to_str().unwrap()).unwrap(); - self.cookies - .insert(cookie.name().to_owned(), cookie.value().to_owned()); - } - } - fn process_internal( &mut self, request: &Request, @@ -57,22 +46,14 @@ impl ActixTestService { actix_request = actix_request.set_header(name, value.clone()); } - actix_request = { - let cookies_value = self - .cookies - .iter() - .map(|(name, value)| format!("{}={}", name, value)) - .collect::>() - .join("; "); - actix_request.set_header(header::COOKIE, cookies_value) - }; + if let Some(ref authorization) = self.authorization { + actix_request = actix_request.bearer_auth(&authorization.token); + } let mut actix_response = self .system_runner .block_on(async move { actix_request.send_json(&body).await.unwrap() }); - self.update_cookies(&actix_response); - let mut response_builder = Response::builder().status(actix_response.status()); let headers = response_builder.headers_mut().unwrap(); for (name, value) in actix_response.headers().iter() { @@ -122,7 +103,7 @@ impl TestService for ActixTestService { }); ActixTestService { - cookies: HashMap::new(), + authorization: None, system_runner, server, } @@ -151,4 +132,8 @@ impl TestService for ActixTestService { let body = serde_json::from_slice(&body.unwrap()).unwrap(); response_builder.body(body).unwrap() } + + fn set_authorization(&mut self, authorization: Option) { + self.authorization = authorization; + } } diff --git a/src/service/dto.rs b/src/service/dto.rs index ac5d790..75d4769 100644 --- a/src/service/dto.rs +++ b/src/service/dto.rs @@ -20,11 +20,23 @@ pub struct InitialSetup { } #[derive(Clone, Serialize, Deserialize)] -pub struct AuthCredentials { +pub struct Credentials { pub username: String, pub password: String, } +#[derive(Clone, Serialize, Deserialize)] +pub struct Authorization { + pub username: String, + pub token: String, + pub is_admin: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct AuthQueryParameters { + pub auth_token: String, +} + #[derive(Serialize, Deserialize)] pub struct ThumbnailOptions { pub pad: Option, @@ -42,8 +54,14 @@ pub struct SavePlaylistInput { #[derive(Serialize, Deserialize)] pub struct LastFMLink { - pub token: String, - pub content: String, + pub auth_token: String, // user::AuthToken emitted by Polaris, valid for LastFMLink scope + pub token: String, // LastFM token for use in scrobble calls + pub content: String, // Payload to send back to client after successful link +} + +#[derive(Serialize, Deserialize)] +pub struct LastFMLinkToken { + pub value: String, } #[derive(Serialize, Deserialize)] diff --git a/src/service/error.rs b/src/service/error.rs index 8f25e33..d07f72c 100644 --- a/src/service/error.rs +++ b/src/service/error.rs @@ -87,6 +87,9 @@ impl From for APIError { user::Error::EmptyUsername => APIError::EmptyUsername, user::Error::EmptyPassword => APIError::EmptyPassword, user::Error::IncorrectUsername => APIError::IncorrectCredentials, + user::Error::IncorrectPassword => APIError::IncorrectCredentials, + user::Error::InvalidAuthToken => APIError::IncorrectCredentials, + user::Error::IncorrectAuthorizationScope => APIError::IncorrectCredentials, user::Error::Unspecified => APIError::Unspecified, } } diff --git a/src/service/mod.rs b/src/service/mod.rs index 8dabf46..f5ea083 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -84,8 +84,9 @@ impl ContextBuilder { let vfs_manager = vfs::Manager::new(db.clone()); let settings_manager = settings::Manager::new(db.clone()); + let auth_secret = settings_manager.get_auth_secret()?; let ddns_manager = ddns::Manager::new(db.clone()); - let user_manager = user::Manager::new(db.clone()); + let user_manager = user::Manager::new(db.clone(), auth_secret); let index = Index::new(db.clone(), vfs_manager.clone(), settings_manager.clone()); let config_manager = config::Manager::new( settings_manager.clone(), diff --git a/src/service/test/auth.rs b/src/service/test/auth.rs index 6e3e40a..c18d171 100644 --- a/src/service/test/auth.rs +++ b/src/service/test/auth.rs @@ -77,9 +77,14 @@ fn test_login_golden_path() { service.complete_initial_setup(); let request = protocol::login(TEST_USERNAME, TEST_PASSWORD); - let response = service.fetch(&request); + let response = service.fetch_json::<_, dto::Authorization>(&request); assert_eq!(response.status(), StatusCode::OK); + let authorization = response.body(); + assert_eq!(authorization.username, TEST_USERNAME); + assert_eq!(authorization.is_admin, false); + assert!(!authorization.token.is_empty()); + validate_added_cookies(&response); } @@ -97,7 +102,7 @@ fn test_requests_without_auth_header_do_not_set_cookies() { } #[test] -fn test_authentication_via_http_header_rejects_bad_username() { +fn test_authentication_via_basic_http_header_rejects_bad_username() { let mut service = ServiceType::new(&test_name!()); service.complete_initial_setup(); @@ -110,7 +115,7 @@ fn test_authentication_via_http_header_rejects_bad_username() { } #[test] -fn test_authentication_via_http_header_rejects_bad_password() { +fn test_authentication_via_basic_http_header_rejects_bad_password() { let mut service = ServiceType::new(&test_name!()); service.complete_initial_setup(); @@ -123,7 +128,7 @@ fn test_authentication_via_http_header_rejects_bad_password() { } #[test] -fn test_authentication_via_http_header_golden_path() { +fn test_authentication_via_basic_http_header_golden_path() { let mut service = ServiceType::new(&test_name!()); service.complete_initial_setup(); @@ -136,3 +141,78 @@ fn test_authentication_via_http_header_golden_path() { validate_added_cookies(&response); } + +#[test] +fn test_authentication_via_bearer_http_header_rejects_bad_token() { + let mut service = ServiceType::new(&test_name!()); + service.complete_initial_setup(); + + let mut request = protocol::random(); + let bearer = headers::Authorization::bearer("garbage").unwrap(); + request.headers_mut().typed_insert(bearer); + + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_authentication_via_bearer_http_header_golden_path() { + let mut service = ServiceType::new(&test_name!()); + service.complete_initial_setup(); + + let authorization = { + let request = protocol::login(TEST_USERNAME, TEST_PASSWORD); + let response = service.fetch_json::<_, dto::Authorization>(&request); + assert_eq!(response.status(), StatusCode::OK); + response.into_body() + }; + + service.logout(); + + let mut request = protocol::random(); + let bearer = headers::Authorization::bearer(&authorization.token).unwrap(); + request.headers_mut().typed_insert(bearer); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + + validate_no_cookies(&response); +} + +#[test] +fn test_authentication_via_query_param_rejects_bad_token() { + let mut service = ServiceType::new(&test_name!()); + service.complete_initial_setup(); + + let mut request = protocol::random(); + *request.uri_mut() = (request.uri().to_string() + "?auth_token=garbage-token") + .parse() + .unwrap(); + + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_authentication_via_query_param_golden_path() { + let mut service = ServiceType::new(&test_name!()); + service.complete_initial_setup(); + + let authorization = { + let request = protocol::login(TEST_USERNAME, TEST_PASSWORD); + let response = service.fetch_json::<_, dto::Authorization>(&request); + assert_eq!(response.status(), StatusCode::OK); + response.into_body() + }; + + service.logout(); + + let mut request = protocol::random(); + *request.uri_mut() = format!("{}?auth_token={}", request.uri(), authorization.token) + .parse() + .unwrap(); + + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + + validate_no_cookies(&response); +} diff --git a/src/service/test/lastfm.rs b/src/service/test/lastfm.rs index 257077d..3e0c905 100644 --- a/src/service/test/lastfm.rs +++ b/src/service/test/lastfm.rs @@ -1,6 +1,7 @@ use http::StatusCode; use std::path::PathBuf; +use crate::service::dto; use crate::service::test::{constants::*, protocol, ServiceType, TestService}; use crate::test_name; @@ -37,3 +38,25 @@ fn test_lastfm_now_playing_ignores_unlinked_user() { let response = service.fetch(&request); assert_eq!(response.status(), StatusCode::NO_CONTENT); } + +#[test] +fn lastfm_link_token_requires_auth() { + let mut service = ServiceType::new(&test_name!()); + let request = protocol::lastfm_link_token(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn lastfm_link_token_golden_path() { + let mut service = ServiceType::new(&test_name!()); + service.complete_initial_setup(); + service.login(); + + let request = protocol::lastfm_link_token(); + let response = service.fetch_json::<_, dto::LastFMLinkToken>(&request); + assert_eq!(response.status(), StatusCode::OK); + let link_token = response.body(); + assert!(!link_token.value.is_empty()); +} + diff --git a/src/service/test/mod.rs b/src/service/test/mod.rs index 2d3fca2..5601ba8 100644 --- a/src/service/test/mod.rs +++ b/src/service/test/mod.rs @@ -62,18 +62,28 @@ pub trait TestService { assert_eq!(response.status(), StatusCode::OK); } - fn login_admin(&mut self) { - let request = protocol::login(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN); - let response = self.fetch(&request); + fn login_internal(&mut self, username: &str, password: &str) { + let request = protocol::login(username, password); + let response = self.fetch_json::<_, dto::Authorization>(&request); assert_eq!(response.status(), StatusCode::OK); + let authorization = response.into_body(); + self.set_authorization(Some(authorization)); + } + + fn login_admin(&mut self) { + self.login_internal(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN); } fn login(&mut self) { - let request = protocol::login(TEST_USERNAME, TEST_PASSWORD); - let response = self.fetch(&request); - assert_eq!(response.status(), StatusCode::OK); + self.login_internal(TEST_USERNAME, TEST_PASSWORD); } + fn logout(&mut self) { + self.set_authorization(None); + } + + fn set_authorization(&mut self, authorization: Option); + fn index(&mut self) { let request = protocol::trigger_index(); let response = self.fetch(&request); diff --git a/src/service/test/protocol.rs b/src/service/test/protocol.rs index 93adb11..1123ae7 100644 --- a/src/service/test/protocol.rs +++ b/src/service/test/protocol.rs @@ -37,8 +37,8 @@ pub fn initial_setup() -> Request<()> { .unwrap() } -pub fn login(username: &str, password: &str) -> Request { - let credentials = dto::AuthCredentials { +pub fn login(username: &str, password: &str) -> Request { + let credentials = dto::Credentials { username: username.into(), password: password.into(), }; @@ -253,6 +253,14 @@ pub fn delete_playlist(name: &str) -> Request<()> { .unwrap() } +pub fn lastfm_link_token() -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/lastfm/link_token") + .body(()) + .unwrap() +} + pub fn lastfm_now_playing(path: &Path) -> Request<()> { let path = path.to_string_lossy(); let endpoint = format!("/api/lastfm/now_playing/{}", url_encode(path.as_ref()));