Repository navigation
Improve the Pluggable Authentication Backends #634
Replies: 6 comments
|
This is great! I think having a typed/generic backend and session-scoped impl Project for MyProject {
fn auth_backends(&self, context: &AuthBackendContext) -> Vec<Arc<dyn AuthBackend>> {
...
}
}With multiple backends registered, a natural next step is letting a user chain them and try each one in the registered order. We could make pub trait AuthBackend: Send + Sync {
async fn authenticate(
&self,
_credentials: &(dyn Any + Send + Sync),
) -> Result<Option<Box<dyn User + Send + Sync>>> {
Err(AuthError::CredentialsTypeNotSupported)
}
}
struct Auth { ... }
impl Auth {
pub async fn authenticate(
&self,
order: &[&'static str],
credentials: &(dyn Any + Send + Sync),
) -> Result<Option<(&'static str, Box<dyn User + Send + Sync>)>> {
for name in order {
let Some(backend) = self.inner.backends.get(*name) else { continue };
match backend.authenticate(credentials).await {
Ok(Some(user)) => return Ok(Some((name, user))),
Ok(None) => continue,
Err(AuthError::CredentialsTypeNotSupported) => continue,
Err(other) => return Err(other),
}
}
Ok(None)
}
}Authentication methods with multi-step logins, like On the pub trait CanonicalUser: Model + User + Send + Sync {
async fn get_or_create_by_external_id(
db: &Database,
...
) -> Result<Self>
where
Self: Sized;
}
struct OidcBackend<U: CanonicalUser> {
...
database: Database,
_user: PhantomData<fn() -> U>,
}
impl<U: CanonicalUser> OidcBackend<U> {
async fn callback(&self, query: CallbackQuery, ctx: &AuthCtx<'_, Self>) -> Result<Box<dyn User + Send + Sync>> {
// ...verify the ID token, get claims..
let user = U::get_or_create_by_external_id(
&self.database,
...
).await?;
Ok(Box::new(user))
}
}Then in client code: #[model]
struct AppUser {
#[model(primary_key)]
id: Auto<i64>,
display_name: LimitedString<255>,
is_active: bool,
joined: DateTime<FixedOffset>,
some_id: i64,
}
impl CanonicalUser for AppUser {
async fn get_or_create_by_external_id(...) -> Result<Self> {
// let downstream users tell us how they want their user saved or retrieved
...
}
}The nice part is that the project specifies its own model at registration time, and we never need to know about AppUser's fields: impl Project for MyProject {
fn auth_backends(&self, context: &AuthBackendContext) -> Vec<Arc<dyn AuthBackend>> {
vec![
Arc::new(OidcBackend::<AppUser>::new(context.database().clone(), ...)),
]
}
}We could also reach for a middle ground (a blend of both worlds), where we provide a default #[model]
pub struct DefaultCanonicalUser {
#[model(primary_key)]
id: Auto<i64>,
display_name: LimitedString<255>,
email: Option<String>,
is_active: bool,
joined: DateTime<FixedOffset>,
}
#[model]
struct DefaultExternalIdentity {
#[model(primary_key)]
id: Auto<i64>,
backend_name: LimitedString<64>,
external_id: String,
#[model(foreign_key(on_delete = "cascade"))]
user: ForeignKey<DefaultCanonicalUser>,
}
impl User for DefaultCanonicalUser { ...}
impl CanonicalUser for DefaultCanonicalUser {
async fn get_or_create_by_external_id(... ) -> Result<Self> {
...
}
}Then at registration time: impl Project for MyProject {
fn auth_backends(&self, context: &AuthBackendContext) -> Vec<Arc<dyn AuthBackend>> {
vec![Arc::new(OidcBackend::<DefaultCanonicalUser>::new(
context.database().clone(),
...
))]
}
}This is kind of similar to what Django does with |
|
My two cents: I think having a chain of authentication backends and One thing that could be good to keep in mind is that it would be nice to somehow integrate the system into the admin panel. For now, the panel is pretty much hardcoded username + password; it would be nice if we could somehow support more complex authentication backends. I think that specifying custom HTML is probably too tied to the particular login process representation, but maybe a few predefined login methods could do the trick? impl AuthBackend for MyAuth {
fn login_flow(&self) -> LoginFlow {
LoginFlow::UsernamePassword
// could also be:
// LoginFlow::RedirectToLoginPage("/oidc/login/") - this could show a "login with OIDC" button on the login page
// LoginFlow::None - for authentication flows that are not session-based (e.g. JWT for REST APIs)
}
}Perhaps |
|
Thanks for the good discussion! I have some thoughts on the individual ideas:
I agree, this is an easy next step once the system is changed.
I have also thought about this, and while I initially thought this would be a good fit, on further consideration I struggled to find combinations of backends where this would be useful:
The only combination where I think this provides value would be LDAP+Username and Password, as we can "fallback" to the local DB. Another issue with a generic interface is that it weakens the compile-time guarantees for providing the correct argument types. Maybe I am overlooking something here though.
This is a good idea, I think Django provides both variants (Satellite model and also "overridable" user model), we could specialize on one. For the concrete implementation, I am not yet sure if we can completely decouple this from I think there are two ways forward here:
For Option 2, I would suggest the following structure: /// User should contain user_id() as Option<UserId>, since the backend is responsible for generating a new ID
pub trait UserStorageBackend<U: User> {
async fn get(&self, id: UserId) -> Result<Option<U>>;
async fn delete(&self, id: UserId) ->Result<()>;
async fn create(&self, user: U) -> Result<U>;
}We could provide a default implementation This functionality cannot be coupled to Also, we probably should pin the used model in the Project instead of making it generic for each Backend: impl Project for MyProject {
type AppUser: User = MyUserModel;
...
}We could also automatically downcast the model in I believe this approach could be valuable especially for an LDAP backend, since we can "virtually" construct users on-demand However, this direction is probably much more maintenance effort in the long term. In Django, even when using LDAP, the user model is still stored in the Database:
In contrast to that, Flask is more similar to the API presented above, with its user_loader. Ultimately, I think it comes down to preference, whether Cot should offer a more "out-of-the-box" solution, or be less opinionated at the cost of integration and coherence in the ecosystem.
Regardless of the specifics discussed before, I think this is a great idea. One consideration here: How can we support migration from a Default user model to a custom one? I am currently unsure which extensions to DB migration tracking or maybe annotations would be required.
For a first integration, I believe we could just let the user specify a route handler where the login options is presented. To make an automatic integration with something like In Django, each 3rd party auth backend is expected to provide a replacement for the admin login page, exactly due to this problem. In the default code, username and password fields are hardcoded: https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/login.html Another idea would be using "template partials" to let each backend directly inject components into the login page, but this would not really solve the use case of LDAP+DB, as they would duplicate the login forms probably. Maybe there is some other way to abstract this? To reduce complexity in Cot, it is probably a good idea in general to rely on OIDC and third party solutions like Authentik / Keycloak for more complex login flows (involving TOTP, Passkeys, ...) instead of adding every possible combination to Cot. Some flexibility should be still provided, so we probably need to find a middle ground here somehow. |
|
Hey everyone, I have now come up with this first prototype design for the #![allow(missing_docs, async_fn_in_trait)]
use std::{borrow::Cow, error::Error};
use cot_macros::model;
use crate::{
auth::UserId,
db::{Auto, Database, Identifier, Model, query::Expr},
};
/// Bikeshed required for this
pub type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync>>;
/// The main user object for the Cot authentication system
pub trait CanonicalUser
where
Self: Sized,
{
/// Link to the backend which stores and loads these users
type Backend: CanonicalUserBackend<Self>;
/// Returns the id of the user, if known (database users may only know this after insertion)
async fn id(&self) -> Option<UserId>;
/// Returns the user name of the user, if specified
async fn user_name<'a>(&'a self) -> Option<Cow<'a, str>>;
}
/// Storage backend for the canonical users.
/// Each project may only have one canonical user type (and, in turn, only have one backend for these users)
/// Usually, you probably want to use the `CanonicalDatabaseBackend` provided by Cot with your own user type
/// Alternatively, auth backends such as LDAP may introduce their own backend here.
pub trait CanonicalUserBackend<U: CanonicalUser> {
async fn save(&self, user: U) -> Result<U>;
async fn get_by_id(&self, id: UserId) -> Result<Option<U>>;
async fn get_by_username<'a>(&self, name: Cow<'a, str>) -> Result<Option<U>>;
async fn delete(&self, id: UserId) -> Result<()>;
}
pub trait CanonicalDatabaseCompat {
const USERNAME_COLUMN: Identifier;
}
// --------- DEFAULT IMPLEMENTATIONS ---------
#[derive(Debug, Clone)]
#[model]
pub struct DefaultCanonicalUser {
#[model(primary_key)]
id: Auto<i64>,
user_name: String,
}
impl CanonicalUser for DefaultCanonicalUser {
type Backend = CanonicalDatabaseBackend;
async fn id(&self) -> Option<UserId> {
match self.id {
Auto::Auto => None,
Auto::Fixed(v) => Some(UserId::Int(v)),
}
}
async fn user_name<'a>(&'a self) -> Option<Cow<'a, str>> {
Some(Cow::Borrowed(&self.user_name))
}
}
impl CanonicalDatabaseCompat for DefaultCanonicalUser {
const USERNAME_COLUMN: Identifier = Identifier::new("user_name");
}
#[derive(Debug)]
pub struct CanonicalDatabaseBackend {
database: Database,
}
impl<U> CanonicalUserBackend<U> for CanonicalDatabaseBackend
where
U: CanonicalUser + CanonicalDatabaseCompat + Model<PrimaryKey = Auto<i64>>,
{
async fn save(&self, mut user: U) -> Result<U> {
<U as Model>::save(&mut user, &self.database).await?;
let user = U::get_by_primary_key(&self.database, *user.primary_key()).await?;
let Some(user) = user else {
unreachable!("Just saved this user?")
};
Ok(user)
}
async fn get_by_id(&self, id: UserId) -> Result<Option<U>> {
let UserId::Int(id) = id else {
return Err("We cannot use string IDs".into());
};
let user = U::get_by_primary_key(&self.database, Auto::Fixed(id)).await?;
Ok(user)
}
async fn get_by_username<'a>(&self, name: Cow<'a, str>) -> Result<Option<U>> {
let user = U::objects()
.filter(Expr::Eq(
Expr::Field(U::USERNAME_COLUMN).into(),
Expr::value(name.to_string()).into(),
))
.get(&self.database)
.await?;
Ok(user)
}
async fn delete(&self, id: UserId) -> Result<()> {
let UserId::Int(id) = id else {
return Err("We cannot use string IDs".into());
};
U::objects()
.filter(Expr::Eq(
Expr::Field(U::PRIMARY_KEY_NAME).into(),
Expr::value(id).into(),
))
.delete(&self.database)
.await?;
Ok(())
}
}One can specify their own model like this: #[derive(Debug, Clone)]
#[model]
pub struct MyCanonicalUser {
#[model(primary_key)]
id: Auto<i64>,
user_name: String,
my_amazing_field: u64,
another_beautiful_field: String,
}
impl CanonicalDatabaseCompat for MyCanonicalUser {
const USERNAME_COLUMN: Identifier = Identifier::new("user_name");
}
impl CanonicalUser for MyCanonicalUser {
type Backend = CanonicalDatabaseBackend;
async fn id(&self) -> Option<UserId> {
match self.id {
Auto::Fixed(v) => Some(UserId::Int(v)),
Auto::Auto => None,
}
}
async fn user_name<'a>(&'a self) -> Option<Cow<'a, str>> {
Some(Cow::Borrowed(&self.user_name))
}
}There are still some rough edges (for example the need to specify the column in which the user name is stored, and also the |
|
After tinkering some more, I think I found a good solution for the Each Auth "type" is its own trait, and we collect them in an enum: #![allow(missing_docs, async_fn_in_trait, missing_debug_implementations)]
use std::{any::Any, error::Error};
use async_trait::async_trait;
use crate::{
auth::{UserId, canonical::CanonicalUser},
common_types::{Password, Url},
};
pub type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync>>;
pub enum AuthenticationBackend<U: CanonicalUser, S> {
Key(Box<dyn KeyBackend<U>>),
UsernamePassword(Box<dyn UsernamePasswordBackend<U>>),
RedirectAuthenticate(Box<dyn RedirectAuthenticateBackend<U, CallbackState = S>>),
Generic(Box<dyn GenericAuthBackend<U>>),
}
#[async_trait]
pub trait UsernamePasswordBackend<U: CanonicalUser> {
async fn authenticate(&self, username: String, password: Password) -> Result<Option<UserId>>;
}
#[async_trait]
pub trait KeyBackend<U: CanonicalUser> {
async fn authenticate(&self, key: Vec<u8>) -> Result<Option<UserId>>;
}
#[async_trait]
pub trait RedirectAuthenticateBackend<U: CanonicalUser> {
type CallbackState;
async fn redirect(&self) -> Result<Url>;
async fn authenticate(&self, state: Self::CallbackState) -> Result<Option<UserId>>;
}
#[async_trait]
pub trait GenericAuthBackend<U: CanonicalUser> {
async fn authenticate(&self, credentials: Box<dyn Any + Send + Sync>)
-> Result<Option<UserId>>;
}This way, we can just match on the backend for the admin form, and even "chain" multiple backends of the same type easily fn test() {
let backend = MockLDAPBackend::<DefaultCanonicalUser>::new();
let backend = AuthBackend::UsernamePassword(Box::new(backend));
let list = vec![backend];
for backend in list {
match backend {
AuthBackend::Key(key_backend) => todo!(),
AuthBackend::UsernamePassword(username_password_backend) => {
username_password_backend.authenticate(username, password);
},
AuthBackend::RedirectAuthenticate(redirect_authenticate_backend) => todo!(),
AuthBackend::Generic(generic_auth_backend) => todo!(),
}
}
}For external implementors, we even keep the ability to opt out and provide any other interface with |
|
Hey, thanks a lot for spending time on this and for your patience waiting for our response! In general, I couldn't find anything I would strongly disagree with, so I think you're free to proceed with the implementation. Perhaps we'll find some flaws when we have some actual code, but so far I think the proposal is reasonable. I'll of course let @seqre and @ElijahAhianyo let us know what they think if they want to.
Option 2 sounds reasonable, although I'm wondering about any long-term maintenance consequences. On one hand, this sounds like the most flexible solution; on the other hand, we want the ORM to support non-relational databases, so I think that would mostly remove the need for such interface. I'm not quite sure - I think 1. could be okay for now for simplicity, and we can always migrate to 2 if we decide it's needed.
Good question. One solution I thought of was that maybe we should just extend the default project template with a simple user structure, so that you will always use a custom To be quite honest, I never liked the Django approach where the default model was somewhat hidden and then migrating to a custom one was quite a pain. I was wondering if we could do better than that, hence the idea above.
That makes sense - this gets tricky, however, when we want to combine multiple auth backends, but perhaps this is getting overly flexible anyways.
It's not ideal either, as we basically now expose the login page as a public API (and have to maintain some level of backwards compatibility because of that).
Yeah, I think this might actually be good enough. This will essentially all the common use cases, and if somebody wants to go with something completely exotic, redirect backend should do. I was thinking about a similar solution in the past, and I couldn't think of anything better.
To be fair, having passkey/WebAuthn support would be nice, but I'm not familiar with the specification enough to know how much work would that be and whether it makes sense to have it as a built-in solution. Maybe in the future, as some "blessed" contrib crate, though? |
Uh oh!
There was an error while loading. Please reload this page.
Currently,
AuthBackend, which is the main trait for implementing new authentication methods in Cot, enforces the implementation of theauthenticatemethod:However, this does not fit well with flow-based authentication methods, such as the commonly used OpenID-Connect.
OIDC consists of multiple steps:
/login) without any credentials/callback) with proof values for the successful authenticationFor implementing this in Cot, we therefore need to call our auth backend twice, once to obtain the redirect URL, and another time to actually "log in".
With the current
AuthBackendtrait, this flow is very awkward, since it requires up-and downcasting values many times, and also manually storing the intermediate values in the user session.The easy part of the change
To make this easier, the smallest change would be to make the authentication concrete to each specific backend, removing the
authenticatemethod onAuthBackend, and then provide keyed access to theSessionstorage:This makes it easy to have both OIDC and Password (and potentially other) backends, maybe even at the same time.
Implementing this API could be done via a registry keyed by the name of the backend:
The difficult part
After this change, there are still multiple problems:
Userimplementation with a different ID schemaUsermodel without also defining a customAuthBackendA possible solution: An
InternalUserDatabase ModelTo solve all of these issues at once, we could introduce a shared minimal database table for Users in Cot, which contains a UID and common information such as the display name.
The backends could then use their own tables for additional data, referencing the key of the main model, or have some other form of data storage which can store that key.
On each login with a backend, the corresponding information in the main table gets updated.
One could also think of a backend that may want to update this for all users on a scheduled basis (e.g. LDAP).
If we need to query backend-specific information (for example, if an account has been invalidated since our login), we can ask the backend directly. To keep track of the "source" backend the login was made with, we can store its name in the user session.
This design immediately solves all of our remaining issues:
2. We can now use the primary key of the main table to reference a User across any backend
3. Using our main table, we can list at least the basic details about all Users which are currently registered
4. This also gets much easier, as explained shortly
To address point 4, we can simply create a second model which references the user in the main table:
and then provide a hook in our backend to provision this data when a user is created:
This could also directly be used to allow "access control lists" of provisioned users, where the app developer can reject users based on the return type of the provisioning hook.
Drawbacks
After all these nice benefits, there are however some drawbacks as well:
dbfeatures in now mandatory for any usage ofauthsince we need to store the main table somewhereComparison to Django
Funnily enough, something quite similar was discussed in the early days of Django: https://code.djangoproject.com/ticket/3669
Feedback
This is only a first draft of my ideas, please feel free to correct things or propose improvements!
All reactions