Skip to content

Commit 7684d83

Browse files
authored
authentication skeleton (#314)
1 parent cb89d96 commit 7684d83

File tree

12 files changed

+1028
-15
lines changed

12 files changed

+1028
-15
lines changed

nexus/examples/config-file.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
# Identifier for this instance of Nexus
66
id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c"
77

8+
# List of authentication schemes to support.
9+
#
10+
# This is not fleshed out yet and the only reason to change it now is for
11+
# working on authentication or authorization. Neither is really implemented
12+
# yet.
13+
authn_schemes_external = []
14+
815
[database]
916
# URL for connecting to the database
10-
url = "postgresql://root@127.0.0.1:26257?sslmode=disable"
17+
url = "postgresql://root@127.0.0.1:26257/omicron?sslmode=disable"
1118

1219
[dropshot_external]
1320
# IP address and TCP port on which to listen for the external API

nexus/examples/config.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
# Identifier for this instance of Nexus
66
id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c"
77

8+
# List of authentication schemes to support.
9+
#
10+
# This is not fleshed out yet and the only reason to change it now is for
11+
# working on authentication or authorization. Neither is really implemented
12+
# yet.
13+
authn_schemes_external = []
14+
815
[database]
916
# URL for connecting to the database
1017
url = "postgresql://root@127.0.0.1:32221/omicron?sslmode=disable"

nexus/src/authn/external/mod.rs

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
//! Authentication for requests to the external HTTP API
2+
3+
use crate::authn;
4+
use authn::Reason;
5+
6+
pub mod spoof;
7+
8+
/// Authenticates incoming HTTP requests using schemes intended for use by the
9+
/// external API
10+
///
11+
/// (This will eventually support something like HTTP signatures and OAuth. For
12+
/// now, only a dummy scheme is supported.)
13+
pub struct Authenticator<T> {
14+
allowed_schemes: Vec<Box<dyn HttpAuthnScheme<T>>>,
15+
}
16+
17+
impl<T> Authenticator<T>
18+
where
19+
T: Send + Sync + 'static,
20+
{
21+
/// Build a new authentiator that allows only the specified schemes
22+
pub fn new(
23+
allowed_schemes: Vec<Box<dyn HttpAuthnScheme<T>>>,
24+
) -> Authenticator<T> {
25+
Authenticator { allowed_schemes }
26+
}
27+
28+
/// Authenticate an incoming HTTP request
29+
// TODO-openapi: At some point, the authentication headers need to get into
30+
// the OpenAPI spec. We probably don't want to have every endpoint function
31+
// accept them via an extractor, though.
32+
pub async fn authn_request(
33+
&self,
34+
rqctx: &dropshot::RequestContext<T>,
35+
) -> Result<authn::Context, authn::Error> {
36+
let log = &rqctx.log;
37+
let request = &rqctx.request.lock().await;
38+
let ctx = rqctx.context();
39+
self.authn_request_generic(ctx, log, request).await
40+
}
41+
42+
/// Authenticate an incoming HTTP request (dropshot-agnostic)
43+
pub async fn authn_request_generic(
44+
&self,
45+
ctx: &T,
46+
log: &slog::Logger,
47+
request: &http::Request<hyper::Body>,
48+
) -> Result<authn::Context, authn::Error> {
49+
// For debuggability, keep track of the schemes that we've tried.
50+
let mut schemes_tried = Vec::with_capacity(self.allowed_schemes.len());
51+
for scheme_impl in &self.allowed_schemes {
52+
let scheme_name = scheme_impl.name();
53+
trace!(log, "authn: trying {:?}", scheme_name);
54+
schemes_tried.push(scheme_name);
55+
let result = scheme_impl.authn(ctx, log, &request);
56+
match result {
57+
// TODO-security If the user explicitly failed one
58+
// authentication scheme (i.e., a signature that didn't match,
59+
// NOT that they simply didn't try), should we try the others
60+
// instead of returning the failure here?
61+
SchemeResult::Failed(reason) => {
62+
return Err(authn::Error { reason, schemes_tried })
63+
}
64+
SchemeResult::Authenticated(details) => {
65+
return Ok(authn::Context {
66+
kind: authn::Kind::Authenticated(details),
67+
schemes_tried,
68+
})
69+
}
70+
SchemeResult::NotRequested => (),
71+
}
72+
}
73+
74+
Ok(authn::Context { kind: authn::Kind::Unauthenticated, schemes_tried })
75+
}
76+
}
77+
78+
/// Implements a particular HTTP authentication scheme
79+
pub trait HttpAuthnScheme<T>: std::fmt::Debug + Send + Sync + 'static
80+
where
81+
T: Send + Sync + 'static,
82+
{
83+
/// Returns the (unique) name for this scheme (for observability)
84+
fn name(&self) -> authn::SchemeName;
85+
86+
/// Locate credentials in the HTTP request and attempt to verify them
87+
fn authn(
88+
&self,
89+
ctx: &T,
90+
log: &slog::Logger,
91+
request: &http::Request<hyper::Body>,
92+
) -> SchemeResult;
93+
}
94+
95+
/// Result returned by each authentication scheme when trying to authenticate a
96+
/// request
97+
#[derive(Debug)]
98+
pub enum SchemeResult {
99+
/// The client is not trying to use this authn scheme
100+
NotRequested,
101+
/// The client successfully authenticated
102+
Authenticated(super::Details),
103+
/// The client tried and failed to authenticate
104+
Failed(Reason),
105+
}
106+
107+
#[cfg(test)]
108+
mod test {
109+
use super::*;
110+
use anyhow::anyhow;
111+
use std::sync::atomic::AtomicU8;
112+
use std::sync::atomic::Ordering;
113+
use std::sync::Arc;
114+
115+
/// HttpAuthnScheme that we can precisely control
116+
#[derive(Debug)]
117+
struct GruntScheme {
118+
/// unique name for this grunt
119+
name: authn::SchemeName,
120+
121+
/// Specifies what to do with the next authn request that we get
122+
///
123+
/// See "SKIP", "OK", and "FAIL" below.
124+
next: Arc<AtomicU8>,
125+
126+
/// number of times we've been asked to authn a request
127+
nattempts: Arc<AtomicU8>,
128+
129+
/// actor to use when authenticated
130+
actor: authn::Actor,
131+
}
132+
133+
// Values of the "next" bool
134+
const SKIP: u8 = 0;
135+
const OK: u8 = 1;
136+
const FAIL: u8 = 2;
137+
138+
impl HttpAuthnScheme<()> for GruntScheme {
139+
fn name(&self) -> authn::SchemeName {
140+
self.name
141+
}
142+
143+
fn authn(
144+
&self,
145+
_ctx: &(),
146+
_log: &slog::Logger,
147+
_request: &http::Request<hyper::Body>,
148+
) -> SchemeResult {
149+
self.nattempts.fetch_add(1, Ordering::SeqCst);
150+
match self.next.load(Ordering::SeqCst) {
151+
SKIP => SchemeResult::NotRequested,
152+
OK => SchemeResult::Authenticated(authn::Details {
153+
actor: self.actor,
154+
}),
155+
FAIL => SchemeResult::Failed(Reason::BadCredentials {
156+
actor: self.actor,
157+
source: anyhow!("grunt error"),
158+
}),
159+
_ => panic!("unrecognized grunt instruction"),
160+
}
161+
}
162+
}
163+
164+
#[tokio::test]
165+
async fn test_authn_sequence() {
166+
// This test verifies the basic behavior of Authenticator by setting up
167+
// a chain of two authn schemes that we can control and measure. We
168+
// will verify:
169+
//
170+
// - when the first scheme returns "authenticated" or an error, we take
171+
// its result and don't even consult the second scheme
172+
// - when the first scheme returns "unauthenticated", we consult the
173+
// second scheme and use its result
174+
// - when both schemes return "unauthenticated", we get back an
175+
// unauthenticated context
176+
177+
// Set up the Authenticator with two GruntSchemes.
178+
let flag1 = Arc::new(AtomicU8::new(SKIP));
179+
let count1 = Arc::new(AtomicU8::new(0));
180+
let mut expected_count1 = 0;
181+
let name1 = authn::SchemeName("grunt1");
182+
let actor1 = authn::Actor(
183+
"1c91bab2-4841-669f-cc32-de80da5bbf39".parse().unwrap(),
184+
);
185+
let grunt1 = Box::new(GruntScheme {
186+
name: name1,
187+
next: Arc::clone(&flag1),
188+
nattempts: Arc::clone(&count1),
189+
actor: actor1,
190+
}) as Box<dyn HttpAuthnScheme<()>>;
191+
192+
let flag2 = Arc::new(AtomicU8::new(SKIP));
193+
let count2 = Arc::new(AtomicU8::new(0));
194+
let mut expected_count2 = 0;
195+
let name2 = authn::SchemeName("grunt2");
196+
let actor2 = authn::Actor(
197+
"799684af-533a-cb66-b5ac-ab55a791d5ef".parse().unwrap(),
198+
);
199+
let grunt2 = Box::new(GruntScheme {
200+
name: name2,
201+
next: Arc::clone(&flag2),
202+
nattempts: Arc::clone(&count2),
203+
actor: actor2,
204+
}) as Box<dyn HttpAuthnScheme<()>>;
205+
206+
let authn = Authenticator::new(vec![grunt1, grunt2]);
207+
let request = http::Request::builder()
208+
.uri("/unused")
209+
.body(hyper::Body::empty())
210+
.unwrap();
211+
212+
let log = slog::Logger::root(slog::Discard, o!());
213+
214+
// With this initial state, both grunts will report that authn was not
215+
// requested. We should wind up with an unauthenticated context with
216+
// both grunts having been consulted.
217+
let ctx = authn
218+
.authn_request_generic(&(), &log, &request)
219+
.await
220+
.expect("expected authn to succeed");
221+
expected_count1 += 1;
222+
expected_count2 += 1;
223+
assert_eq!(ctx.schemes_tried(), &[name1, name2]);
224+
assert_eq!(ctx.actor(), None);
225+
assert_eq!(expected_count1, count1.load(Ordering::SeqCst));
226+
assert_eq!(expected_count2, count2.load(Ordering::SeqCst));
227+
228+
// Now let's configure grunt1 to authenticate the user. We should get
229+
// back an authenticated context with grunt1's actor id. grunt2 should
230+
// not be consulted.
231+
flag1.store(OK, Ordering::SeqCst);
232+
let ctx = authn
233+
.authn_request_generic(&(), &log, &request)
234+
.await
235+
.expect("expected authn to succeed");
236+
expected_count1 += 1;
237+
assert_eq!(ctx.schemes_tried(), &[name1]);
238+
assert_eq!(ctx.actor(), Some(&actor1));
239+
assert_eq!(expected_count1, count1.load(Ordering::SeqCst));
240+
assert_eq!(expected_count2, count2.load(Ordering::SeqCst));
241+
242+
// Now let's configure grunt1 to fail authentication. We should get
243+
// back an error. grunt2 should not be consulted.
244+
flag1.store(FAIL, Ordering::SeqCst);
245+
let error = authn
246+
.authn_request_generic(&(), &log, &request)
247+
.await
248+
.expect_err("expected authn to fail");
249+
expected_count1 += 1;
250+
assert_eq!(
251+
error.to_string(),
252+
"authentication failed (tried schemes: [SchemeName(\"grunt1\")])"
253+
);
254+
assert_eq!(expected_count1, count1.load(Ordering::SeqCst));
255+
assert_eq!(expected_count2, count2.load(Ordering::SeqCst));
256+
257+
// We've now verified that grunt2 is not consulted unless grunt1 reports
258+
// that authentication was not requested. Let's configure grunt1 to do
259+
// exactly that and have grunt2 successfully authenticate.
260+
flag1.store(SKIP, Ordering::SeqCst);
261+
flag2.store(OK, Ordering::SeqCst);
262+
let ctx = authn
263+
.authn_request_generic(&(), &log, &request)
264+
.await
265+
.expect("expected authn to succeed");
266+
expected_count1 += 1;
267+
expected_count2 += 1;
268+
assert_eq!(ctx.schemes_tried(), &[name1, name2]);
269+
assert_eq!(ctx.actor(), Some(&actor2));
270+
assert_eq!(expected_count1, count1.load(Ordering::SeqCst));
271+
assert_eq!(expected_count2, count2.load(Ordering::SeqCst));
272+
273+
// Now configure grunt2 to fail.
274+
flag2.store(FAIL, Ordering::SeqCst);
275+
expected_count1 += 1;
276+
expected_count2 += 1;
277+
let error = authn
278+
.authn_request_generic(&(), &log, &request)
279+
.await
280+
.expect_err("expected authn to fail");
281+
assert_eq!(
282+
error.to_string(),
283+
"authentication failed (tried schemes: \
284+
[SchemeName(\"grunt1\"), SchemeName(\"grunt2\")])"
285+
);
286+
assert_eq!(expected_count1, count1.load(Ordering::SeqCst));
287+
assert_eq!(expected_count2, count2.load(Ordering::SeqCst));
288+
}
289+
}

0 commit comments

Comments
 (0)