Support for bearer token authentication ()

* User manager can create and recognize auth tokens

* Implement HTTP bearer auth

* Use bearer auth in test-harness

* Can receive auth token via query parameter (useful for media endpoints)
This commit is contained in:
Antoine Gersant 2020-12-20 03:25:45 -08:00 committed by GitHub
parent 5e065c5e6a
commit 72c8ed9289
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 737 additions and 131 deletions

45
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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": {},

View file

@ -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(

View file

@ -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<AuthToken> {
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<()> {

View file

@ -6,7 +6,7 @@ mod manager;
pub use error::*;
pub use manager::*;
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct AuthSecret {
pub key: [u8; 32],
}

View file

@ -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,
}

View file

@ -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<bool, Error> {
pub fn login(&self, username: &str, password: &str) -> Result<AuthToken, Error> {
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<Authorization, Error> {
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<Authorization, Error> {
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<AuthToken, Error> {
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<i64> {
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<bool> {
pub fn is_admin(&self, username: &str) -> Result<bool, Error> {
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<AuthToken, Error> {
self.generate_auth_token(&Authorization {
username: username.to_owned(),
scope: AuthorizationScope::LastFMLink,
})
}
pub fn get_lastfm_session_key(&self, username: &str) -> anyhow::Result<String> {
use crate::db::users::dsl::*;
let connection = self.db.connect()?;

View file

@ -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,
}

View file

@ -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
)
}

View file

@ -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 =

View file

@ -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::<dto::AuthQueryParameters>::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<user::Manager>,
credentials: Json<dto::AuthCredentials>,
credentials: Json<dto::Credentials>,
cookies: Cookies,
) -> Result<HttpResponse, APIError> {
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<lastfm::Manager>,
auth: Auth,
) -> Result<Json<dto::LastFMLinkToken>, 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<lastfm::Manager>,
auth: Auth,
user_manager: Data<user::Manager>,
payload: web::Query<dto::LastFMLink>,
) -> Result<HttpResponse, APIError> {
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();

View file

@ -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<String, String>,
authorization: Option<dto::Authorization>,
server: TestServer,
}
pub type ServiceType = ActixTestService;
impl ActixTestService {
fn update_cookies<T>(&mut self, actix_response: &ClientResponse<T>) {
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<T: Serialize + Clone + 'static>(
&mut self,
request: &Request<T>,
@ -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::<Vec<_>>()
.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<dto::Authorization>) {
self.authorization = authorization;
}
}

View file

@ -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<bool>,
@ -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)]

View file

@ -87,6 +87,9 @@ impl From<user::Error> 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,
}
}

View file

@ -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(),

View file

@ -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);
}

View file

@ -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());
}

View file

@ -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<dto::Authorization>);
fn index(&mut self) {
let request = protocol::trigger_index();
let response = self.fetch(&request);

View file

@ -37,8 +37,8 @@ pub fn initial_setup() -> Request<()> {
.unwrap()
}
pub fn login(username: &str, password: &str) -> Request<dto::AuthCredentials> {
let credentials = dto::AuthCredentials {
pub fn login(username: &str, password: &str) -> Request<dto::Credentials> {
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()));