feat(server): support multiple auth tokens (#84)

* feat(server): support multiple auth tokens

Example:

```toml
[server]
auth_tokens = [
  "super_secret_token1",
  "super_secret_token2",
]
```

The previously used `AUTH_TOKEN` environment variable can still be used
and will be evaluated as well.

* fixtures: add all tokens in array to the test

* add deprecation warning for auth_token

* also add deprecation warnings at server startup

* fix formatting

* fixed tests, so that we do not use deprecated config options

* use bash array

* refactor: use separate function

* refactor: check auth tokens

* Update fixtures/test-server-auth-multiple-tokens/test.sh

Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>

* refactor: convert functions to methods

* refactor: check function

* refactor: get_tokens method

* style(format): add newline between functions

* refactor(server): print deprecation warnings once

---------

Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
This commit is contained in:
Helmut K. C. Tessarek 2023-07-21 06:28:17 -04:00 committed by GitHub
parent b1bdc45767
commit 0d4808880f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 120 additions and 46 deletions

View File

@ -219,7 +219,7 @@ $ curl -F "remote=https://example.com/file.png" "<server_address>"
#### Cleaning up expired files
Configure `delete_expired_files` to set an interval for deleting the expired files automatically.
Configure `[paste].delete_expired_files` to set an interval for deleting the expired files automatically.
On the other hand, following script can be used as [cron](https://en.wikipedia.org/wiki/Cron) for cleaning up the expired files manually:
@ -253,14 +253,16 @@ $ echo "AUTH_TOKEN=$(openssl rand -base64 16)" > .env
$ rustypaste
```
You can also set multiple auth tokens via the array field `[server].auth_tokens` in your `config.toml`.
See [config.toml](./config.toml) for configuration options.
#### HTML Form
It is possible to use an HTML form for uploading files. To do so, you need to update two fields in your `config.toml`:
- Set the `landing_page_content_type` to `text/html; charset=utf-8`.
- Update the `landing_page` field with your HTML form.
- Set the `[landing_page].content_type` to `text/html; charset=utf-8`.
- Update the `[landing_page].text` field with your HTML form or point `[landing_page].file` to your html file.
For an example, see [examples/html_form.toml](./examples/html_form.toml)

View File

@ -9,6 +9,10 @@ max_content_length = "10MB"
upload_path = "./upload"
timeout = "30s"
expose_version = false
#auth_tokens = [
# "super_secret_token1",
# "super_secret_token2",
#]
[landing_page]
text = """

View File

@ -0,0 +1,10 @@
[server]
address="127.0.0.1:8000"
max_content_length="10MB"
upload_path="./upload"
auth_tokens=["token1", "token2", "rustypasteisawesome", "token4"]
[paste]
random_url = { enabled = false, type = "petname", words = 2, separator = "-" }
default_extension = "txt"
duplicate_files = false

View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
auth_tokens=("rustypasteisawesome" "token1" "token2" "token4")
content="topsecret"
setup() {
echo "$content" > file
}
run_test() {
result=$(curl -s -F "file=@file" localhost:8000)
test "unauthorized" = "$result"
for auth_token in ${auth_tokens[@]}
do
result=$(curl -s -F "file=@file" -H "Authorization: $auth_token" localhost:8000)
test "unauthorized" != "$result"
test "$content" = "$(cat upload/file.txt)"
test "$content" = "$(curl -s $result)"
done
}
teardown() {
rm file
rm -r upload
}

View File

@ -4,21 +4,19 @@ use actix_web::{error, Error};
/// Checks the authorization header for the specified token.
///
/// `Authorization: (type) <token>`
pub fn check(host: &str, headers: &HeaderMap, token: Option<String>) -> Result<(), Error> {
if let Some(token) = token {
if !token.is_empty() {
let auth_header = headers
.get(AUTHORIZATION)
.map(|v| v.to_str().unwrap_or_default())
.map(|v| v.split_whitespace().last().unwrap_or_default());
if auth_header.unwrap_or_default() != token {
log::warn!(
"authorization failure for {} (header: {})",
host,
auth_header.unwrap_or("none"),
);
return Err(error::ErrorUnauthorized("unauthorized"));
}
pub fn check(host: &str, headers: &HeaderMap, tokens: Option<Vec<String>>) -> Result<(), Error> {
if let Some(tokens) = tokens {
let auth_header = headers
.get(AUTHORIZATION)
.map(|v| v.to_str().unwrap_or_default())
.map(|v| v.split_whitespace().last().unwrap_or_default());
if !tokens.iter().any(|v| v == auth_header.unwrap_or_default()) {
log::warn!(
"authorization failure for {} (header: {})",
host,
auth_header.unwrap_or("none"),
);
return Err(error::ErrorUnauthorized("unauthorized"));
}
}
Ok(())
@ -33,11 +31,23 @@ mod tests {
fn test_check_auth() -> Result<(), Error> {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("basic test_token"));
assert!(check("", &headers, Some(String::from("test_token"))).is_ok());
assert!(check("", &headers, Some(String::from("invalid_token"))).is_err());
assert!(check("", &headers, Some(vec!["test_token".to_string()])).is_ok());
assert!(check("", &headers, Some(vec!["invalid_token".to_string()])).is_err());
assert!(check(
"",
&headers,
Some(vec!["invalid1".to_string(), "test_token".to_string()])
)
.is_ok());
assert!(check(
"",
&headers,
Some(vec!["invalid1".to_string(), "invalid2".to_string()])
)
.is_err());
assert!(check("", &headers, None).is_ok());
assert!(check("", &HeaderMap::new(), None).is_ok());
assert!(check("", &HeaderMap::new(), Some(String::from("token"))).is_err());
assert!(check("", &HeaderMap::new(), Some(vec!["token".to_string()])).is_err());
Ok(())
}
}

View File

@ -1,7 +1,9 @@
use crate::mime::MimeMatcher;
use crate::random::RandomURLConfig;
use crate::AUTH_TOKEN_ENV;
use byte_unit::Byte;
use config::{self, ConfigError};
use std::env;
use std::path::{Path, PathBuf};
use std::time::Duration;
@ -44,7 +46,10 @@ pub struct ServerConfig {
#[serde(default, with = "humantime_serde")]
pub timeout: Option<Duration>,
/// Authentication token.
#[deprecated(note = "use [server].auth_tokens instead")]
pub auth_token: Option<String>,
/// Authentication tokens.
pub auth_tokens: Option<Vec<String>>,
/// Expose version.
pub expose_version: Option<bool>,
/// Landing page text.
@ -107,6 +112,35 @@ impl Config {
.build()?
.try_deserialize()
}
/// Retrieves all configured tokens.
#[allow(deprecated)]
pub fn get_tokens(&self) -> Option<Vec<String>> {
let mut tokens = self.server.auth_tokens.clone().unwrap_or_default();
if let Some(token) = &self.server.auth_token {
tokens.insert(0, token.to_string());
}
if let Ok(env_token) = env::var(AUTH_TOKEN_ENV) {
tokens.insert(0, env_token);
}
(!tokens.is_empty()).then_some(tokens)
}
/// Print deprecation warnings.
#[allow(deprecated)]
pub fn warn_deprecation(&self) {
if self.server.auth_token.is_some() {
log::warn!("[server].auth_token is deprecated, please use [server].auth_tokens");
}
if self.server.landing_page.is_some() {
log::warn!("[server].landing_page is deprecated, please use [landing_page].text");
}
if self.server.landing_page_content_type.is_some() {
log::warn!(
"[server].landing_page_content_type is deprecated, please use [landing_page].content_type"
);
}
}
}
#[cfg(test)]

View File

@ -48,6 +48,7 @@ fn setup(config_folder: &Path) -> IoResult<(Data<RwLock<Config>>, ServerConfig,
};
let config = Config::parse(&config_path).expect("failed to parse config");
log::trace!("{:#?}", config);
config.warn_deprecation();
let server_config = config.server.clone();
let paste_config = RwLock::new(config.paste.clone());
let (config_sender, config_receiver) = mpsc::channel::<Config>();

View File

@ -5,7 +5,6 @@ use crate::header::{self, ContentDisposition};
use crate::mime as mime_util;
use crate::paste::{Paste, PasteType};
use crate::util;
use crate::AUTH_TOKEN_ENV;
use actix_files::NamedFile;
use actix_multipart::Multipart;
use actix_web::{error, get, post, web, Error, HttpRequest, HttpResponse};
@ -37,7 +36,6 @@ async fn index(config: web::Data<RwLock<Config>>) -> Result<HttpResponse, Error>
if let Some(ref mut landing_page) = config.landing_page {
landing_page.text = config.server.landing_page;
}
log::warn!("[server].landing_page is deprecated, please use [landing_page].text");
}
if config.server.landing_page_content_type.is_some() {
if config.landing_page.is_none() {
@ -46,9 +44,6 @@ async fn index(config: web::Data<RwLock<Config>>) -> Result<HttpResponse, Error>
if let Some(ref mut landing_page) = config.landing_page {
landing_page.content_type = config.server.landing_page_content_type;
}
log::warn!(
"[server].landing_page_content_type is deprecated, please use [landing_page].content_type"
);
}
if let Some(mut landing_page) = config.landing_page {
if let Some(file) = landing_page.file {
@ -159,13 +154,8 @@ async fn version(
.map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?;
let connection = request.connection_info().clone();
let host = connection.realip_remote_addr().unwrap_or("unknown host");
auth::check(
host,
request.headers(),
env::var(AUTH_TOKEN_ENV)
.ok()
.or_else(|| config.server.auth_token.as_ref().cloned()),
)?;
let tokens = config.get_tokens();
auth::check(host, request.headers(), tokens)?;
if !config.server.expose_version.unwrap_or(false) {
log::warn!("server is not configured to expose version endpoint");
Err(error::ErrorForbidden("endpoint is not exposed"))?;
@ -184,6 +174,13 @@ async fn upload(
) -> Result<HttpResponse, Error> {
let connection = request.connection_info().clone();
let host = connection.realip_remote_addr().unwrap_or("unknown host");
{
let config = config
.read()
.map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?;
let tokens = config.get_tokens();
auth::check(host, request.headers(), tokens)?;
}
let server_url = match config
.read()
.map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?
@ -196,17 +193,6 @@ async fn upload(
format!("{}://{}", connection.scheme(), connection.host(),)
}
};
auth::check(
host,
request.headers(),
env::var(AUTH_TOKEN_ENV).ok().or(config
.read()
.map_err(|_| error::ErrorInternalServerError("cannot acquire config"))?
.server
.auth_token
.as_ref()
.cloned()),
)?;
let time = util::get_system_time()?;
let mut expiry_date = header::parse_expiry_date(request.headers(), time)?;
if expiry_date.is_none() {
@ -462,7 +448,7 @@ mod tests {
#[actix_web::test]
async fn test_version_without_auth() -> Result<(), Error> {
let mut config = Config::default();
config.server.auth_token = Some(String::from("test"));
config.server.auth_tokens = Some(vec!["test".to_string()]);
let app = test::init_service(
App::new()
.app_data(Data::new(RwLock::new(config)))
@ -526,7 +512,7 @@ mod tests {
#[actix_web::test]
async fn test_auth() -> Result<(), Error> {
let mut config = Config::default();
config.server.auth_token = Some(String::from("test"));
config.server.auth_tokens = Some(vec!["test".to_string()]);
let app = test::init_service(
App::new()