Browse Source

Add most of a working OAuth2 implementation

master
Puck Meerburg 10 months ago
parent
commit
03cbb70d3c
6 changed files with 483 additions and 11 deletions
  1. 4
    0
      Cargo.toml
  2. 74
    5
      src/auth.html
  3. 10
    0
      src/bin/hash.rs
  4. 303
    6
      src/endpoints.rs
  5. 15
    0
      src/lib.rs
  6. 77
    0
      src/pass.rs

+ 4
- 0
Cargo.toml View File

@@ -15,3 +15,7 @@ serde_json = "1.0"
jsonld = { path = "../jsonld-rs" }
kroeg-mastodon = { path = "../mastodon" }
url = "1.7"
rand = "0.6"
lazy_static = "1.2"
rust-crypto = "^0.2"
openssl = "0.10"

+ 74
- 5
src/auth.html View File

@@ -1,5 +1,74 @@
<form action="{REDIRECT}" method="GET">
<input type="text" name="code" placeholder="[access token]" />
<input type="hidden" name="state" value="{STATE}" />
<input type="submit" />
</form>
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<style>
html,
body {
height: 100%;
}

body {
display: flex;
align-items: center;

padding-top: 40px;
padding-bottom: 40px;
background-color: #404040;
}

form {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}

.form-control {
position: relative;
box-sizing: border-box;
height: auto;
padding: 10px;
font-size: 16px;
background: #404040;
border-color: #707070;
color: #eee;
}

.form-control:focus {
background: #303030;
z-index: 2;
color: #eee;
}

#username {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}

#password {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>
</head>


<body class="text-center">
<form method="POST">
<input type="hidden" name="state" value="{STATE}" />
<input type="hidden" name="redirect_uri" value="{REDIRECT}" />
<input type="hidden" name="response_type" value="{RESPONSE}" />

<label for="username" class="sr-only">Username (URL)</label>
<input type="text" id="username" autocomplete="off" class="form-control" name="username" placeholder="https://example.com/" required autofocus>

<label for="password" class="sr-only">Password</label>
<input type="password" id="password" class="form-control" name="password" placeholder="Password" required>

<button class="btn btn-lg btn-primary btn-block" type="submit">Log in</button>
</form>
</body>
</html>

+ 10
- 0
src/bin/hash.rs View File

@@ -0,0 +1,10 @@
extern crate kroeg_oauth;

use kroeg_oauth::PasswordVerifier;
use std::env;

fn main() {
for item in env::args().skip(1) {
println!("{}", PasswordVerifier::hash(&item).to_string());
}
}

+ 303
- 6
src/endpoints.rs View File

@@ -1,10 +1,26 @@
use futures::{future, Future, Stream};
use base64;
use futures::{
future::{self, Either},
Future, Stream,
};
use hyper::{Body, Request, Response};
use jsonld::nodemap::{Pointer, Value};
use kroeg_server::ServerError;
use kroeg_tap::{Context, EntityStore, QueueStore};
use kroeg_tap::{Context, EntityStore, QueueStore, StoreItem};
use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa, sign::Signer};
use pass::PasswordVerifier;
use serde_json::Value as JValue;
use std::collections::HashMap;
use std::sync::Mutex;
use url::form_urlencoded;

use rand::rngs::OsRng;
use rand::RngCore;

lazy_static! {
static ref TOKEN_MAP: Mutex<HashMap<String, String>> = Mutex::new(HashMap::new());
}

pub fn authorize<T: EntityStore, R: QueueStore>(
_: Context,
store: T,
@@ -17,6 +33,10 @@ pub fn authorize<T: EntityStore, R: QueueStore>(

let text = include_str!("auth.html")
.replace("{STATE}", pairs.get("state").unwrap_or(&String::from("")))
.replace(
"{RESPONSE}",
pairs.get("response_type").unwrap_or(&String::from("code")),
)
.replace("{REDIRECT}", &pairs["redirect_uri"]);
Box::new(future::ok((
store,
@@ -28,6 +48,275 @@ pub fn authorize<T: EntityStore, R: QueueStore>(
)))
}

fn build_auth_token<T: EntityStore>(
store: T,
actor: StoreItem,
as_code: bool,
) -> impl Future<Item = (T, Option<String>), Error = (T::Error, T)> + Send {
if let Some(Pointer::Id(key)) = actor.main()["https://w3id.org/security#publicKey"]
.get(0)
.cloned()
{
Either::A(store.get(key, true).and_then(move |(item, store)| {
match item {
Some(keyitem) => {
if let Some(Pointer::Value(Value {
value: JValue::String(key),
..
})) = keyitem.sub("https://puckipedia.com/kroeg/ns#meta").unwrap()
["https://w3id.org/security#privateKeyPem"]
.get(0)
{
let pkey =
PKey::from_rsa(Rsa::private_key_from_pem(key.as_bytes()).unwrap())
.unwrap();
let mut signer = Signer::new(MessageDigest::sha256(), &pkey).unwrap();

let header = base64::encode_config(
json!({
"typ": "JWT",
"alg": "RS256",
"kid": keyitem.id()
})
.to_string()
.as_bytes(),
base64::URL_SAFE_NO_PAD,
);

let contents = base64::encode_config(
json!({
"iss": "oauth",
"sub": actor.id(),
"exp": 0xFFFFFFFFu32
})
.to_string()
.as_bytes(),
base64::URL_SAFE_NO_PAD,
);

signer
.update(format!("{}.{}", header, contents).as_bytes())
.unwrap();
let signature = base64::encode_config(
&signer.sign_to_vec().unwrap(),
base64::URL_SAFE_NO_PAD,
);

let auth_token = format!("{}.{}.{}", header, contents, signature);

if as_code {
let mut placeholder = [0; 30];
OsRng::new().unwrap().fill_bytes(&mut placeholder);
let placeholder = base64::encode(&placeholder);

TOKEN_MAP
.lock()
.unwrap()
.insert(placeholder.to_owned(), auth_token);
future::ok((store, Some(placeholder)))
} else {
future::ok((store, Some(auth_token)))
}
} else {
future::ok((store, None))
}
}
None => future::ok((store, None)),
}
}))
} else {
Either::B(future::ok((store, None)))
}
}

fn handle_login<T: EntityStore>(
store: T,
username: String,
password: String,
as_code: bool,
) -> impl Future<Item = (T, Option<String>), Error = (T::Error, T)> + Send {
store
.get(username, true)
.and_then(move |(f, store)| match f {
Some(item) => {
if let Some(Pointer::Value(Value {
value: JValue::String(hash),
..
})) = item.sub("https://puckipedia.com/kroeg/ns#meta").unwrap()
["https://puckipedia.com/kroeg/ns#password"]
.get(0)
.cloned()
{
if let Some(verifier) = PasswordVerifier::decode(&hash) {
let (correct, _rehash) = verifier.verify(&password);

if correct {
Either::A(build_auth_token(store, item, as_code))
} else {
Either::B(future::ok((store, None)))
}
} else {
Either::B(future::ok((store, None)))
}
} else {
Either::B(future::ok((store, None)))
}
}

None => Either::B(future::ok((store, None))),
})
}

fn build_redirect(
url: &str,
state: Option<&str>,
token: Option<&str>,
error: Option<&str>,
as_code: bool,
) -> String {
let finish = match url {
url if as_code && url.contains("?") => "&",
url if as_code && !url.contains("?") => "?",
url if !as_code && url.contains("#") => "&",
_ => "#",
};
match (state, token) {
(Some(state), Some(token)) if as_code => {
format!("{}{}state={}&code={}", url, finish, state, token)
}
(None, Some(token)) if as_code => format!("{}{}code={}", url, finish, token),

(Some(state), Some(token)) => format!(
"{}{}state={}&access_token={}&token_type=bearer&expires_in=9999999",
url, finish, state, token
),
(None, Some(token)) => format!(
"{}{}access_token={}&token_type=bearer&expires_in=9999999",
url, finish, token
),

(Some(state), None) => format!(
"{}{}state={}&error={}",
url,
finish,
state,
error.unwrap_or("access_denied")
),
(None, None) => format!(
"{}{}error={}",
url,
finish,
error.unwrap_or("access_denied")
),
}
}

pub fn login<T: EntityStore, R: QueueStore>(
_: Context,
store: T,
queue: R,
request: Request<Body>,
) -> Box<Future<Item = (T, R, Response<Body>), Error = (ServerError<T>, T)> + Send> {
let body = request.into_body();
Box::new(
body.concat2()
.then(move |f| match f {
Ok(body) => future::ok((body, store)),
Err(e) => future::err((ServerError::HyperError(e), store)),
})
.and_then(move |(val, store)| {
let mut pairs = form_urlencoded::parse(val.as_ref())
.into_owned()
.collect::<HashMap<_, _>>();
let state = match pairs.remove("state") {
Some(val) => {
if val == "" {
None
} else {
Some(val)
}
}
None => None,
};

let as_code = pairs
.remove("response_type")
.map(|f| f != "token")
.unwrap_or(true);
match (
pairs.remove("redirect_uri"),
pairs.remove("username"),
pairs.remove("password"),
) {
(Some(redirect_uri), Some(username), Some(password)) => Either::A(
handle_login(store, username, password, as_code)
.map(move |(store, token)| {
let token = token.as_ref().map(|f| f as &str);
let state = state.as_ref().map(|f| f as &str);

(
store,
queue,
Response::builder()
.status(302)
.header(
"Location",
build_redirect(
&redirect_uri,
state,
token,
None,
as_code,
),
)
.body(Body::from(format!(
"<a href=\"{}\">click here</a>",
build_redirect(
&redirect_uri,
state,
token,
None,
as_code
)
)))
.unwrap(),
)
})
.map_err(|(e, store)| (ServerError::StoreError(e), store)),
),

(Some(redirect_uri), _, _) => Either::B(future::ok((
store,
queue,
Response::builder()
.status(302)
.header(
"Location",
build_redirect(
&redirect_uri,
state.as_ref().map(|f| f as &str),
None,
Some("invalid_request"),
as_code,
),
)
.body(Body::from("invalid request"))
.unwrap(),
))),

_ => Either::B(future::ok((
store,
queue,
Response::builder()
.header("Content-Type", "text/plain")
.body(Body::from("invalid request"))
.unwrap(),
))),
}
}),
)
}

pub fn token<T: EntityStore, R: QueueStore>(
_: Context,
store: T,
@@ -41,9 +330,17 @@ pub fn token<T: EntityStore, R: QueueStore>(
Err(e) => future::err((ServerError::HyperError(e), store)),
}).map(move |(val, store)| {
let pairs = form_urlencoded::parse(val.as_ref()).into_owned().collect::<HashMap<_, _>>();
(store, queue, Response::builder()
.header("Content-Type", "application/json")
.body(Body::from(json!({ "access_token": &pairs["code"], "token_type": "Bearer", "expires_in": 9999999 }).to_string()))
.unwrap())
if let Some(code) = TOKEN_MAP.lock().unwrap().remove(&pairs["code"]) {
(store, queue, Response::builder()
.header("Content-Type", "application/json")
.body(Body::from(json!({ "access_token": code, "token_type": "Bearer", "expires_in": 9999999 }).to_string()))
.unwrap())
} else {
(store, queue, Response::builder()
.status(401)
.header("Content-Type", "application/json")
.body(Body::from(json!({ "error": "invalid_grant" }).to_string()))
.unwrap())
}
}))
}

+ 15
- 0
src/lib.rs View File

@@ -1,4 +1,5 @@
extern crate base64;
extern crate crypto;
extern crate futures;
extern crate hyper;
extern crate jsonld;
@@ -10,23 +11,37 @@ extern crate serde_derive;
#[macro_use]
extern crate serde_json;
extern crate url;
#[macro_use]
extern crate lazy_static;
extern crate openssl;
extern crate rand;

use kroeg_server::router::Route;
use kroeg_server::KroegServiceBuilder;

mod endpoints;
mod pass;
mod register;

pub use pass::PasswordVerifier;

pub fn register(builder: &mut KroegServiceBuilder) {
builder
.routes
.push(Route::post("/api/v1/apps", Box::new(register::mastodon)));

builder.routes.push(Route::get(
"/oauth/authorize",
Box::new(endpoints::authorize),
));

builder
.routes
.push(Route::post("/oauth/authorize", Box::new(endpoints::login)));

builder
.routes
.push(Route::post("/oauth/token", Box::new(endpoints::token)));

println!(" [+] OAuth registered");
}

+ 77
- 0
src/pass.rs View File

@@ -0,0 +1,77 @@
use base64::{decode, encode};
use crypto::blake2b::Blake2b;
use crypto::mac::Mac;
use crypto::pbkdf2::pbkdf2;
use crypto::util::fixed_time_eq;

use rand::rngs::OsRng;
use rand::RngCore;

pub enum PasswordVerifier {
/// Blake2b + PBKDF2, salt + iterations
/// Saved as `$kroeg$1$base64(salt)$int(iterations)$base64(hash)
Version1(Vec<u8>, u32, Vec<u8>),
}

impl ToString for PasswordVerifier {
fn to_string(&self) -> String {
match self {
PasswordVerifier::Version1(salt, iterations, hash) => {
format!("$kroeg$1${}${}${}", encode(salt), iterations, encode(hash))
}
}
}
}

impl PasswordVerifier {
pub fn hash(password: &str) -> PasswordVerifier {
let mut blake2b = Blake2b::new(64);
let mut salt = [0; 32];

OsRng::new().unwrap().fill_bytes(&mut salt);

let mut out = [0; 64];
blake2b.input(password.as_bytes());
pbkdf2(&mut blake2b, &salt, 10000, &mut out);

PasswordVerifier::Version1(salt.to_vec(), 10000, out.to_vec())
}

pub fn decode(code: &str) -> Option<PasswordVerifier> {
if !code.starts_with("$kroeg$") {
return None;
}

let parts: Vec<_> = code[1..].split('$').collect();
if parts.len() != 5 {
return None; // invalid length
}

match (
parts[1].parse(),
decode(&parts[2]),
parts[3].parse(),
decode(&parts[4]),
) {
(Ok(1), Ok(salt), Ok(iterations), Ok(hash)) => {
Some(PasswordVerifier::Version1(salt, iterations, hash))
}

_ => None,
}
}

pub fn verify(&self, password: &str) -> (bool, Option<String>) {
match self {
PasswordVerifier::Version1(salt, iterations, hash) => {
let mut blake2b = Blake2b::new(64);
blake2b.input(password.as_bytes());
let mut out = [0; 64];

pbkdf2(&mut blake2b, salt, *iterations, &mut out);

(fixed_time_eq(&out, &hash), None)
}
}
}
}

Loading…
Cancel
Save