Implement AUTHENTICATE clause on RECORD access ()

Co-authored-by: Gerard Guillemas Martos <gguillemas@users.noreply.github.com>
Co-authored-by: Gerard Guillemas Martos <gerard.guillemas@surrealdb.com>
This commit is contained in:
Micha de Vries 2024-07-01 12:37:50 +02:00 committed by GitHub
parent 7cbb0d4b0d
commit 01a05aea90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1055 additions and 68 deletions
core/src

View file

@ -140,11 +140,11 @@ pub async fn db_access(
Ok(val) => {
match val.record() {
// There is a record returned
Some(rid) => {
Some(mut rid) => {
// Create the authentication key
let key = config(iss.alg, iss.key)?;
// Create the authentication claim
let val = Claims {
let claims = Claims {
iss: Some(SERVER_NAME.to_owned()),
iat: Some(Utc::now().timestamp()),
nbf: Some(Utc::now().timestamp()),
@ -156,13 +156,40 @@ pub async fn db_access(
id: Some(rid.to_raw()),
..Claims::default()
};
// AUTHENTICATE clause
if let Some(au) = at.authenticate {
// Setup the system session for finding the signin record
let mut sess =
Session::editor().with_ns(&ns).with_db(&db);
sess.rd = Some(rid.clone().into());
sess.tk = Some(claims.clone().into());
sess.ip.clone_from(&session.ip);
sess.or.clone_from(&session.or);
// Compute the value with the params
match kvs.evaluate(au, &sess, None).await {
Ok(val) => match val.record() {
Some(id) => {
// Update rid with result from AUTHENTICATE clause
rid = id;
}
_ => return Err(Error::InvalidAuth),
},
Err(e) => {
return match e {
Error::Thrown(_) => Err(e),
e if *INSECURE_FORWARD_RECORD_ACCESS_ERRORS => Err(e),
_ => Err(Error::InvalidAuth),
}
}
}
}
// Log the authenticated access method info
trace!("Signing in with access method `{}`", ac);
// Create the authentication token
let enc =
encode(&Header::new(iss.alg.into()), &val, &key);
encode(&Header::new(iss.alg.into()), &claims, &key);
// Set the authentication on the session
session.tk = Some(val.into());
session.tk = Some(claims.into());
session.ns = Some(ns.to_owned());
session.db = Some(db.to_owned());
session.ac = Some(ac.to_owned());
@ -1179,4 +1206,275 @@ dn/RsYEONbwQSjIfMPkvxF+8HQ==
assert!(res.is_err(), "Unexpected successful signin: {:?}", res);
}
}
#[tokio::test]
async fn test_signin_record_and_authenticate_clause() {
// Test with correct credentials
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNIN (
SELECT * FROM type::thing('user', $id)
)
AUTHENTICATE (
-- Simple example increasing the record identifier by one
SELECT * FROM type::thing('user', meta::id($auth) + 1)
)
DURATION FOR SESSION 2h
;
CREATE user:1, user:2;
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("id", 1.into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"user".to_string(),
vars.into(),
)
.await;
assert!(res.is_ok(), "Failed to signin with credentials: {:?}", res);
assert_eq!(sess.ns, Some("test".to_string()));
assert_eq!(sess.db, Some("test".to_string()));
assert_eq!(sess.ac, Some("user".to_string()));
assert_eq!(sess.au.id(), "user:2");
assert!(sess.au.is_record());
assert_eq!(sess.au.level().ns(), Some("test"));
assert_eq!(sess.au.level().db(), Some("test"));
assert_eq!(sess.au.level().id(), Some("user:2"));
// Record users should not have roles
assert!(!sess.au.has_role(&Role::Viewer), "Auth user expected to not have Viewer role");
assert!(!sess.au.has_role(&Role::Editor), "Auth user expected to not have Editor role");
assert!(!sess.au.has_role(&Role::Owner), "Auth user expected to not have Owner role");
// Expiration should match the defined duration
let exp = sess.exp.unwrap();
// Expiration should match the current time plus session duration with some margin
let min_exp = (Utc::now() + Duration::hours(2) - Duration::seconds(10)).timestamp();
let max_exp = (Utc::now() + Duration::hours(2) + Duration::seconds(10)).timestamp();
assert!(
exp > min_exp && exp < max_exp,
"Session expiration is expected to follow the defined duration"
);
}
// Test with correct credentials and "realistic" scenario
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS owner ON DATABASE TYPE RECORD
SIGNUP (
-- Allow anyone to sign up as a new company
-- This automatically creates an owner with the same credentials
CREATE company CONTENT {
email: $email,
pass: crypto::argon2::generate($pass),
owner: (CREATE employee CONTENT {
email: $email,
pass: $pass,
}),
}
)
SIGNIN (
-- Allow company owners to log in directly with the company account
SELECT * FROM company WHERE email = $email AND crypto::argon2::compare(pass, $pass)
)
AUTHENTICATE (
-- If logging in with a company account, the session will be authenticated as the first owner
IF meta::tb($auth) = "company" {
RETURN SELECT VALUE owner FROM company WHERE id = $auth
}
)
DURATION FOR SESSION 2h
;
CREATE company:1 CONTENT {
email: "info@example.com",
pass: crypto::argon2::generate("company-password"),
owner: employee:2,
};
CREATE employee:1 CONTENT {
email: "member@example.com",
pass: crypto::argon2::generate("member-password"),
};
CREATE employee:2 CONTENT {
email: "owner@example.com",
pass: crypto::argon2::generate("owner-password"),
};
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("email", "info@example.com".into());
vars.insert("pass", "company-password".into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"owner".to_string(),
vars.into(),
)
.await;
assert!(res.is_ok(), "Failed to signin with credentials: {:?}", res);
assert_eq!(sess.ns, Some("test".to_string()));
assert_eq!(sess.db, Some("test".to_string()));
assert_eq!(sess.ac, Some("owner".to_string()));
assert_eq!(sess.au.id(), "employee:2");
assert!(sess.au.is_record());
assert_eq!(sess.au.level().ns(), Some("test"));
assert_eq!(sess.au.level().db(), Some("test"));
assert_eq!(sess.au.level().id(), Some("employee:2"));
// Record users should not have roles
assert!(!sess.au.has_role(&Role::Viewer), "Auth user expected to not have Viewer role");
assert!(!sess.au.has_role(&Role::Editor), "Auth user expected to not have Editor role");
assert!(!sess.au.has_role(&Role::Owner), "Auth user expected to not have Owner role");
// Expiration should match the defined duration
let exp = sess.exp.unwrap();
// Expiration should match the current time plus session duration with some margin
let min_exp = (Utc::now() + Duration::hours(2) - Duration::seconds(10)).timestamp();
let max_exp = (Utc::now() + Duration::hours(2) + Duration::seconds(10)).timestamp();
assert!(
exp > min_exp && exp < max_exp,
"Session expiration is expected to follow the defined duration"
);
}
// Test being able to fail authentication
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNIN (
SELECT * FROM type::thing('user', $id)
)
AUTHENTICATE {
-- Not just signin, this clause runs across signin, signup and authenticate, which makes it a nice place to centralize logic
IF !$auth.enabled {
THROW "This user is not enabled";
};
-- Always need to return the user id back, otherwise auth generically fails
RETURN $auth;
}
DURATION FOR SESSION 2h
;
CREATE user:1 SET enabled = false;
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("id", 1.into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"user".to_string(),
vars.into(),
)
.await;
match res {
Err(Error::Thrown(e)) if e == "This user is not enabled" => {} // ok
res => panic!(
"Expected authentication to failed due to user not being enabled, but instead received: {:?}",
res
),
}
}
// Test AUTHENTICATE clause not returning a value
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNIN (
SELECT * FROM type::thing('user', $id)
)
AUTHENTICATE {}
DURATION FOR SESSION 2h
;
CREATE user:1;
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("id", 1.into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"user".to_string(),
vars.into(),
)
.await;
match res {
Err(Error::InvalidAuth) => {} // ok
res => panic!(
"Expected authentication to generally fail, but instead received: {:?}",
res
),
}
}
}
}

View file

@ -79,11 +79,11 @@ pub async fn db_access(
Ok(val) => {
match val.record() {
// There is a record returned
Some(rid) => {
Some(mut rid) => {
// Create the authentication key
let key = config(iss.alg, iss.key)?;
// Create the authentication claim
let val = Claims {
let claims = Claims {
iss: Some(SERVER_NAME.to_owned()),
iat: Some(Utc::now().timestamp()),
nbf: Some(Utc::now().timestamp()),
@ -95,13 +95,40 @@ pub async fn db_access(
id: Some(rid.to_raw()),
..Claims::default()
};
// AUTHENTICATE clause
if let Some(au) = at.authenticate {
// Setup the system session for finding the signin record
let mut sess =
Session::editor().with_ns(&ns).with_db(&db);
sess.rd = Some(rid.clone().into());
sess.tk = Some(claims.clone().into());
sess.ip.clone_from(&session.ip);
sess.or.clone_from(&session.or);
// Compute the value with the params
match kvs.evaluate(au, &sess, None).await {
Ok(val) => match val.record() {
Some(id) => {
// Update rid with result from AUTHENTICATE clause
rid = id;
}
_ => return Err(Error::InvalidAuth),
},
Err(e) => {
return match e {
Error::Thrown(_) => Err(e),
e if *INSECURE_FORWARD_RECORD_ACCESS_ERRORS => Err(e),
_ => Err(Error::InvalidAuth),
}
}
}
}
// Log the authenticated access method info
trace!("Signing up with access method `{}`", ac);
// Create the authentication token
let enc =
encode(&Header::new(iss.alg.into()), &val, &key);
encode(&Header::new(iss.alg.into()), &claims, &key);
// Set the authentication on the session
session.tk = Some(val.into());
session.tk = Some(claims.into());
session.ns = Some(ns.to_owned());
session.db = Some(db.to_owned());
session.ac = Some(ac.to_owned());
@ -415,4 +442,271 @@ dn/RsYEONbwQSjIfMPkvxF+8HQ==
}
}
}
#[tokio::test]
async fn test_signup_record_and_authenticate_clause() {
// Test with correct credentials
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNUP (
CREATE type::thing('user', $id)
)
AUTHENTICATE (
-- Simple example increasing the record identifier by one
SELECT * FROM type::thing('user', meta::id($auth) + 1)
)
DURATION FOR SESSION 2h
;
CREATE user:2;
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("id", 1.into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"user".to_string(),
vars.into(),
)
.await;
assert!(res.is_ok(), "Failed to signup with credentials: {:?}", res);
assert_eq!(sess.ns, Some("test".to_string()));
assert_eq!(sess.db, Some("test".to_string()));
assert_eq!(sess.ac, Some("user".to_string()));
assert_eq!(sess.au.id(), "user:2");
assert!(sess.au.is_record());
assert_eq!(sess.au.level().ns(), Some("test"));
assert_eq!(sess.au.level().db(), Some("test"));
assert_eq!(sess.au.level().id(), Some("user:2"));
// Record users should not have roles
assert!(!sess.au.has_role(&Role::Viewer), "Auth user expected to not have Viewer role");
assert!(!sess.au.has_role(&Role::Editor), "Auth user expected to not have Editor role");
assert!(!sess.au.has_role(&Role::Owner), "Auth user expected to not have Owner role");
// Expiration should match the defined duration
let exp = sess.exp.unwrap();
// Expiration should match the current time plus session duration with some margin
let min_exp = (Utc::now() + Duration::hours(2) - Duration::seconds(10)).timestamp();
let max_exp = (Utc::now() + Duration::hours(2) + Duration::seconds(10)).timestamp();
assert!(
exp > min_exp && exp < max_exp,
"Session expiration is expected to follow the defined duration"
);
}
// Test with correct credentials and "realistic" scenario
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS owner ON DATABASE TYPE RECORD
SIGNUP (
-- Allow anyone to sign up as a new company
-- This automatically creates an owner with the same credentials
CREATE company CONTENT {
email: $email,
pass: crypto::argon2::generate($pass),
owner: (CREATE employee CONTENT {
email: $email,
pass: $pass,
}),
}
)
SIGNIN (
-- Allow company owners to log in directly with the company account
SELECT * FROM company WHERE email = $email AND crypto::argon2::compare(pass, $pass)
)
AUTHENTICATE (
-- If logging in with a company account, the session will be authenticated as the first owner
IF meta::tb($auth) = "company" {
RETURN SELECT VALUE owner FROM company WHERE id = $auth
}
)
DURATION FOR SESSION 2h
;
CREATE company:1 CONTENT {
email: "info@example.com",
pass: crypto::argon2::generate("company-password"),
owner: employee:2,
};
CREATE employee:1 CONTENT {
email: "member@example.com",
pass: crypto::argon2::generate("member-password"),
};
CREATE employee:2 CONTENT {
email: "owner@example.com",
pass: crypto::argon2::generate("owner-password"),
};
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("email", "info@example.com".into());
vars.insert("pass", "company-password".into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"owner".to_string(),
vars.into(),
)
.await;
assert!(res.is_ok(), "Failed to signin with credentials: {:?}", res);
assert_eq!(sess.ns, Some("test".to_string()));
assert_eq!(sess.db, Some("test".to_string()));
assert_eq!(sess.ac, Some("owner".to_string()));
assert!(sess.au.id().starts_with("employee:"));
assert!(sess.au.is_record());
assert_eq!(sess.au.level().ns(), Some("test"));
assert_eq!(sess.au.level().db(), Some("test"));
assert!(sess.au.level().id().unwrap().starts_with("employee:"));
// Record users should not have roles
assert!(!sess.au.has_role(&Role::Viewer), "Auth user expected to not have Viewer role");
assert!(!sess.au.has_role(&Role::Editor), "Auth user expected to not have Editor role");
assert!(!sess.au.has_role(&Role::Owner), "Auth user expected to not have Owner role");
// Expiration should match the defined duration
let exp = sess.exp.unwrap();
// Expiration should match the current time plus session duration with some margin
let min_exp = (Utc::now() + Duration::hours(2) - Duration::seconds(10)).timestamp();
let max_exp = (Utc::now() + Duration::hours(2) + Duration::seconds(10)).timestamp();
assert!(
exp > min_exp && exp < max_exp,
"Session expiration is expected to follow the defined duration"
);
}
// Test being able to fail authentication
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNUP (
CREATE type::thing('user', $id)
)
AUTHENTICATE {
-- Not just signin, this clause runs across signin, signup and authenticate, which makes it a nice place to centralize logic
IF !$auth.enabled {
THROW "This user is not enabled";
};
-- Always need to return the user id back, otherwise auth generically fails
RETURN $auth;
}
DURATION FOR SESSION 2h
;
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("id", 1.into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"user".to_string(),
vars.into(),
)
.await;
match res {
Err(Error::Thrown(e)) if e == "This user is not enabled" => {} // ok
res => panic!(
"Expected authentication to failed due to user not being enabled, but instead received: {:?}",
res
),
}
}
// Test AUTHENTICATE clause not returning a value
{
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNUP (
CREATE type::thing('user', $id)
)
AUTHENTICATE {}
DURATION FOR SESSION 2h
;
"#,
&sess,
None,
)
.await
.unwrap();
// Signin with the user
let mut sess = Session {
ns: Some("test".to_string()),
db: Some("test".to_string()),
..Default::default()
};
let mut vars: HashMap<&str, Value> = HashMap::new();
vars.insert("id", 1.into());
let res = db_access(
&ds,
&mut sess,
"test".to_string(),
"test".to_string(),
"user".to_string(),
vars.into(),
)
.await;
match res {
Err(Error::InvalidAuth) => {} // ok
res => panic!(
"Expected authentication to generally fail, but instead received: {:?}",
res
),
}
}
}
}

View file

@ -1,3 +1,4 @@
use crate::cnf::INSECURE_FORWARD_RECORD_ACCESS_ERRORS;
use crate::dbs::Session;
use crate::err::Error;
#[cfg(feature = "jwks")]
@ -150,7 +151,7 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
}
}
// Check the token authentication claims
match token_data.claims {
match token_data.claims.clone() {
// Check if this is record access
Claims {
ns: Some(ns),
@ -164,28 +165,58 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
// Create a new readonly transaction
let mut tx = kvs.transaction(Read, Optimistic).await?;
// Parse the record id
let id = syn::thing(&id)?;
let mut rid = syn::thing(&id)?;
// Get the database access method
let de = tx.get_db_access(&ns, &db, &ac).await?;
// Obtain the configuration to verify the token based on the access method
let cf = match de.kind {
AccessType::Record(ac) => match ac.jwt.verify {
JwtAccessVerify::Key(key) => config(key.alg, key.key),
#[cfg(feature = "jwks")]
JwtAccessVerify::Jwks(jwks) => {
if let Some(kid) = token_data.header.kid {
jwks::config(kvs, &kid, &jwks.url, token_data.header.alg).await
} else {
Err(Error::MissingTokenHeader("kid".to_string()))
let (au, cf) = match de.kind {
AccessType::Record(at) => {
let cf = match at.jwt.verify.clone() {
JwtAccessVerify::Key(key) => config(key.alg, key.key),
#[cfg(feature = "jwks")]
JwtAccessVerify::Jwks(jwks) => {
if let Some(kid) = token_data.header.kid {
jwks::config(kvs, &kid, &jwks.url, token_data.header.alg).await
} else {
Err(Error::MissingTokenHeader("kid".to_string()))
}
}
}
#[cfg(not(feature = "jwks"))]
_ => return Err(Error::AccessMethodMismatch),
},
#[cfg(not(feature = "jwks"))]
_ => return Err(Error::AccessMethodMismatch),
}?;
(at.authenticate, cf)
}
_ => return Err(Error::AccessMethodMismatch),
}?;
};
// Verify the token
decode::<Claims>(token, &cf.0, &cf.1)?;
// AUTHENTICATE clause
if let Some(au) = au {
// Setup the system session for finding the signin record
let mut sess = Session::editor().with_ns(&ns).with_db(&db);
sess.rd = Some(rid.clone().into());
sess.tk = Some(token_data.claims.clone().into());
sess.ip.clone_from(&session.ip);
sess.or.clone_from(&session.or);
// Compute the value with the params
match kvs.evaluate(au, &sess, None).await {
Ok(val) => match val.record() {
Some(id) => {
// Update rid with result from AUTHENTICATE clause
rid = id;
}
_ => return Err(Error::InvalidAuth),
},
Err(e) => {
return match e {
Error::Thrown(_) => Err(e),
e if *INSECURE_FORWARD_RECORD_ACCESS_ERRORS => Err(e),
_ => Err(Error::InvalidAuth),
}
}
}
}
// Log the success
debug!("Authenticated with record access method `{}`", ac);
// Set the session
@ -193,16 +224,17 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
session.ns = Some(ns.to_owned());
session.db = Some(db.to_owned());
session.ac = Some(ac.to_owned());
session.rd = Some(Value::from(id.to_owned()));
session.rd = Some(Value::from(rid.to_owned()));
session.exp = expiration(de.duration.session)?;
session.au = Arc::new(Auth::new(Actor::new(
id.to_string(),
rid.to_string(),
Default::default(),
Level::Record(ns, db, id.to_string()),
Level::Record(ns, db, rid.to_string()),
)));
Ok(())
}
// Check if this is database access
// This can also be record access with an authenticate clause
Claims {
ns: Some(ns),
db: Some(db),
@ -216,49 +248,112 @@ pub async fn token(kvs: &Datastore, session: &mut Session, token: &str) -> Resul
// Get the database access method
let de = tx.get_db_access(&ns, &db, &ac).await?;
// Obtain the configuration to verify the token based on the access method
let cf = match de.kind {
AccessType::Jwt(ac) => match ac.verify {
JwtAccessVerify::Key(key) => config(key.alg, key.key),
#[cfg(feature = "jwks")]
JwtAccessVerify::Jwks(jwks) => {
if let Some(kid) = token_data.header.kid {
jwks::config(kvs, &kid, &jwks.url, token_data.header.alg).await
} else {
Err(Error::MissingTokenHeader("kid".to_string()))
match de.kind {
// If the access type is Jwt, this is database access
AccessType::Jwt(at) => {
let cf = match at.verify {
JwtAccessVerify::Key(key) => config(key.alg, key.key),
#[cfg(feature = "jwks")]
JwtAccessVerify::Jwks(jwks) => {
if let Some(kid) = token_data.header.kid {
jwks::config(kvs, &kid, &jwks.url, token_data.header.alg).await
} else {
Err(Error::MissingTokenHeader("kid".to_string()))
}
}
#[cfg(not(feature = "jwks"))]
_ => return Err(Error::AccessMethodMismatch),
}?;
// Verify the token
decode::<Claims>(token, &cf.0, &cf.1)?;
// Parse the roles
let roles = match token_data.claims.roles {
// If no role is provided, grant the viewer role
None => vec![Role::Viewer],
// If roles are provided, parse them
Some(roles) => roles
.iter()
.map(|r| -> Result<Role, Error> {
Role::from_str(r.as_str()).map_err(Error::IamError)
})
.collect::<Result<Vec<_>, _>>()?,
};
// Log the success
debug!("Authenticated to database `{}` with access method `{}`", db, ac);
// Set the session
session.tk = Some(value);
session.ns = Some(ns.to_owned());
session.db = Some(db.to_owned());
session.ac = Some(ac.to_owned());
session.exp = expiration(de.duration.session)?;
session.au = Arc::new(Auth::new(Actor::new(
de.name.to_string(),
roles,
Level::Database(ns, db),
)));
}
// If the access type is Record, this is record access
// Record access without an "id" claim is only possible if there is an AUTHENTICATE clause
// The clause can make up for the missing "id" claim by resolving other claims to a specific record
AccessType::Record(at) => match at.authenticate {
Some(au) => {
trace!("Access method `{}` is record access with authenticate clause", ac);
let cf = match at.jwt.verify {
JwtAccessVerify::Key(key) => config(key.alg, key.key),
#[cfg(feature = "jwks")]
JwtAccessVerify::Jwks(jwks) => {
if let Some(kid) = token_data.header.kid {
jwks::config(kvs, &kid, &jwks.url, token_data.header.alg).await
} else {
Err(Error::MissingTokenHeader("kid".to_string()))
}
}
#[cfg(not(feature = "jwks"))]
_ => return Err(Error::AccessMethodMismatch),
}?;
// Verify the token
decode::<Claims>(token, &cf.0, &cf.1)?;
// AUTHENTICATE clause
// Setup the system session for finding the signin record
let mut sess = Session::editor().with_ns(&ns).with_db(&db);
sess.tk = Some(token_data.claims.clone().into());
sess.ip.clone_from(&session.ip);
sess.or.clone_from(&session.or);
// Compute the value with the params
let rid = match kvs.evaluate(au, &sess, None).await {
Ok(val) => match val.record() {
// If found, return record identifier from AUTHENTICATE clause
Some(id) => id,
_ => return Err(Error::InvalidAuth),
},
Err(e) => {
return match e {
Error::Thrown(_) => Err(e),
e if *INSECURE_FORWARD_RECORD_ACCESS_ERRORS => Err(e),
_ => Err(Error::InvalidAuth),
}
}
};
// Log the success
debug!("Authenticated with record access method `{}`", ac);
// Set the session
session.tk = Some(value);
session.ns = Some(ns.to_owned());
session.db = Some(db.to_owned());
session.ac = Some(ac.to_owned());
session.rd = Some(Value::from(rid.to_owned()));
session.exp = expiration(de.duration.session)?;
session.au = Arc::new(Auth::new(Actor::new(
rid.to_string(),
Default::default(),
Level::Record(ns, db, rid.to_string()),
)));
}
#[cfg(not(feature = "jwks"))]
_ => return Err(Error::AccessMethodMismatch),
},
_ => return Err(Error::AccessMethodMismatch),
}?;
// Verify the token
decode::<Claims>(token, &cf.0, &cf.1)?;
// Parse the roles
let roles = match token_data.claims.roles {
// If no role is provided, grant the viewer role
None => vec![Role::Viewer],
// If roles are provided, parse them
Some(roles) => roles
.iter()
.map(|r| -> Result<Role, Error> {
Role::from_str(r.as_str()).map_err(Error::IamError)
})
.collect::<Result<Vec<_>, _>>()?,
};
// Log the success
debug!("Authenticated to database `{}` with access method `{}`", db, ac);
// Set the session
session.tk = Some(value);
session.ns = Some(ns.to_owned());
session.db = Some(db.to_owned());
session.ac = Some(ac.to_owned());
session.exp = expiration(de.duration.session)?;
session.au = Arc::new(Auth::new(Actor::new(
de.name.to_string(),
roles,
Level::Database(ns, db),
)));
Ok(())
}
// Check if this is database authentication with user credentials
@ -1347,7 +1442,7 @@ mod tests {
// Create the token
let enc = match encode(&HEADER, &claims, &key) {
Ok(enc) => enc,
Err(err) => panic!("Failed to decode token: {:?}", err),
Err(err) => panic!("Failed to encode token: {:?}", err),
};
// Signin with the token
let mut sess = Session::default();
@ -1653,4 +1748,276 @@ mod tests {
assert!(res.is_err(), "Unexpected success signing in with expired token: {:?}", res);
}
#[tokio::test]
async fn test_token_db_record_and_authenticate_clause() {
// Test with an "id" claim
{
let secret = "jwt_secret";
let key = EncodingKey::from_secret(secret.as_ref());
let claims = Claims {
iss: Some("surrealdb-test".to_string()),
iat: Some(Utc::now().timestamp()),
nbf: Some(Utc::now().timestamp()),
exp: Some((Utc::now() + Duration::hours(1)).timestamp()),
ns: Some("test".to_string()),
db: Some("test".to_string()),
ac: Some("user".to_string()),
id: Some("user:1".to_string()),
..Claims::default()
};
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
format!(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
AUTHENTICATE (
-- Simple example increasing the record identifier by one
SELECT * FROM type::thing('user', meta::id($auth) + 1)
)
WITH JWT ALGORITHM HS512 KEY '{secret}'
DURATION FOR SESSION 2h
;
CREATE user:1, user:2;
"#
)
.as_str(),
&sess,
None,
)
.await
.unwrap();
// Prepare the claims object
let mut claims = claims.clone();
claims.roles = None;
// Create the token
let enc = encode(&HEADER, &claims, &key).unwrap();
// Signin with the token
let mut sess = Session::default();
let res = token(&ds, &mut sess, &enc).await;
assert!(res.is_ok(), "Failed to signin with token: {:?}", res);
assert_eq!(sess.ns, Some("test".to_string()));
assert_eq!(sess.db, Some("test".to_string()));
assert_eq!(sess.ac, Some("user".to_string()));
assert_eq!(sess.au.id(), "user:2");
assert!(sess.au.is_record());
assert_eq!(sess.au.level().ns(), Some("test"));
assert_eq!(sess.au.level().db(), Some("test"));
assert_eq!(sess.au.level().id(), Some("user:2"));
// Record users should not have roles
assert!(!sess.au.has_role(&Role::Viewer), "Auth user expected to not have Viewer role");
assert!(!sess.au.has_role(&Role::Editor), "Auth user expected to not have Editor role");
assert!(!sess.au.has_role(&Role::Owner), "Auth user expected to not have Owner role");
// Expiration should match the defined duration
let exp = sess.exp.unwrap();
// Expiration should match the current time plus session duration with some margin
let min_exp = (Utc::now() + Duration::hours(2) - Duration::seconds(10)).timestamp();
let max_exp = (Utc::now() + Duration::hours(2) + Duration::seconds(10)).timestamp();
assert!(
exp > min_exp && exp < max_exp,
"Session expiration is expected to follow the defined duration"
);
}
// Test without an "id" claim
{
let secret = "jwt_secret";
let key = EncodingKey::from_secret(secret.as_ref());
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
format!(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNIN (
SELECT * FROM type::thing('user', $id)
)
AUTHENTICATE (
SELECT id FROM user WHERE email = $token.email
)
WITH JWT ALGORITHM HS512 KEY '{secret}'
DURATION FOR SESSION 2h
;
CREATE user:1 SET email = "info@surrealdb.com";
"#
)
.as_str(),
&sess,
None,
)
.await
.unwrap();
let now = Utc::now().timestamp();
let later = (Utc::now() + Duration::hours(1)).timestamp();
let claims_json = format!(
r#"
{{
"iss": "surrealdb-test",
"iat": {now},
"nbf": {now},
"exp": {later},
"ns": "test",
"db": "test",
"ac": "user",
"email": "info@surrealdb.com"
}}
"#
);
let claims = serde_json::from_str::<Claims>(&claims_json).unwrap();
// Create the token
let enc = match encode(&HEADER, &claims, &key) {
Ok(enc) => enc,
Err(err) => panic!("Failed to encode token: {:?}", err),
};
// Signin with the token
let mut sess = Session::default();
let res = token(&ds, &mut sess, &enc).await;
assert!(res.is_ok(), "Failed to signin with token: {:?}", res);
assert_eq!(sess.ns, Some("test".to_string()));
assert_eq!(sess.db, Some("test".to_string()));
assert_eq!(sess.ac, Some("user".to_string()));
assert_eq!(sess.au.id(), "user:1");
assert!(sess.au.is_record());
assert_eq!(sess.au.level().ns(), Some("test"));
assert_eq!(sess.au.level().db(), Some("test"));
assert_eq!(sess.au.level().id(), Some("user:1"));
// Record users should not have roles
assert!(!sess.au.has_role(&Role::Viewer), "Auth user expected to not have Viewer role");
assert!(!sess.au.has_role(&Role::Editor), "Auth user expected to not have Editor role");
assert!(!sess.au.has_role(&Role::Owner), "Auth user expected to not have Owner role");
// Expiration should match the defined duration
let exp = sess.exp.unwrap();
// Expiration should match the current time plus session duration with some margin
let min_exp = (Utc::now() + Duration::hours(2) - Duration::seconds(10)).timestamp();
let max_exp = (Utc::now() + Duration::hours(2) + Duration::seconds(10)).timestamp();
assert!(
exp > min_exp && exp < max_exp,
"Session expiration is expected to follow the defined duration"
);
}
// Test being able to fail authentication
{
let secret = "jwt_secret";
let key = EncodingKey::from_secret(secret.as_ref());
let claims = Claims {
iss: Some("surrealdb-test".to_string()),
iat: Some(Utc::now().timestamp()),
nbf: Some(Utc::now().timestamp()),
exp: Some((Utc::now() + Duration::hours(1)).timestamp()),
ns: Some("test".to_string()),
db: Some("test".to_string()),
ac: Some("user".to_string()),
id: Some("user:1".to_string()),
..Claims::default()
};
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
format!(r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
AUTHENTICATE {{
-- Not just signin, this clause runs across signin, signup and authenticate, which makes it a nice place to centralize logic
IF !$auth.enabled {{
THROW "This user is not enabled";
}};
-- Always need to return the user id back, otherwise auth generically fails
RETURN $auth;
}}
WITH JWT ALGORITHM HS512 KEY '{secret}'
DURATION FOR SESSION 2h
;
CREATE user:1 SET enabled = false;
"#).as_str(),
&sess,
None,
)
.await
.unwrap();
// Prepare the claims object
let mut claims = claims.clone();
claims.roles = None;
// Create the token
let enc = encode(&HEADER, &claims, &key).unwrap();
// Signin with the token
let mut sess = Session::default();
let res = token(&ds, &mut sess, &enc).await;
match res {
Err(Error::Thrown(e)) if e == "This user is not enabled" => {} // ok
res => panic!(
"Expected authentication to failed due to user not being enabled, but instead received: {:?}",
res
),
}
}
// Test AUTHENTICATE clause not returning a value
{
let secret = "jwt_secret";
let key = EncodingKey::from_secret(secret.as_ref());
let claims = Claims {
iss: Some("surrealdb-test".to_string()),
iat: Some(Utc::now().timestamp()),
nbf: Some(Utc::now().timestamp()),
exp: Some((Utc::now() + Duration::hours(1)).timestamp()),
ns: Some("test".to_string()),
db: Some("test".to_string()),
ac: Some("user".to_string()),
id: Some("user:test".to_string()),
..Claims::default()
};
let ds = Datastore::new("memory").await.unwrap();
let sess = Session::owner().with_ns("test").with_db("test");
ds.execute(
format!(
r#"
DEFINE ACCESS user ON DATABASE TYPE RECORD
AUTHENTICATE {{}}
WITH JWT ALGORITHM HS512 KEY '{secret}'
DURATION FOR SESSION 2h
;
CREATE user:1;
"#
)
.as_str(),
&sess,
None,
)
.await
.unwrap();
// Prepare the claims object
let mut claims = claims.clone();
claims.roles = None;
// Create the token
let enc = encode(&HEADER, &claims, &key).unwrap();
// Signin with the token
let mut sess = Session::default();
let res = token(&ds, &mut sess, &enc).await;
match res {
Err(Error::InvalidAuth) => {} // ok
res => panic!(
"Expected authentication to generally fail, but instead received: {:?}",
res
),
}
}
}
}

View file

@ -40,6 +40,9 @@ impl Display for AccessType {
if let Some(ref v) = ac.signin {
write!(f, " SIGNIN {v}")?
}
if let Some(ref v) = ac.authenticate {
write!(f, " AUTHENTICATE {v}")?
}
write!(f, " WITH JWT {}", ac.jwt)?;
}
}
@ -59,6 +62,7 @@ impl InfoStructure for AccessType {
"jwt".to_string() => v.jwt.structure(),
"signup".to_string(), if let Some(v) = v.signup => v.structure(),
"signin".to_string(), if let Some(v) = v.signin => v.structure(),
"authenticate".to_string(), if let Some(v) = v.authenticate => v.structure(),
}),
}
}
@ -262,6 +266,7 @@ pub struct JwtAccessVerifyJwks {
pub struct RecordAccess {
pub signup: Option<Value>,
pub signin: Option<Value>,
pub authenticate: Option<Value>,
pub jwt: JwtAccess,
}
@ -270,6 +275,7 @@ impl Default for RecordAccess {
Self {
signup: None,
signin: None,
authenticate: None,
jwt: JwtAccess {
..Default::default()
},

View file

@ -83,6 +83,7 @@ impl ser::Serializer for SerializerRecord {
pub struct SerializeRecord {
pub signup: Option<Value>,
pub signin: Option<Value>,
pub authenticate: Option<Value>,
pub jwt: JwtAccess,
}
@ -101,6 +102,9 @@ impl serde::ser::SerializeStruct for SerializeRecord {
"signin" => {
self.signin = value.serialize(ser::value::opt::Serializer.wrap())?;
}
"authenticate" => {
self.authenticate = value.serialize(ser::value::opt::Serializer.wrap())?;
}
"jwt" => {
self.jwt = value.serialize(SerializerJwt.wrap())?;
}
@ -115,6 +119,7 @@ impl serde::ser::SerializeStruct for SerializeRecord {
Ok(RecordAccess {
signup: self.signup,
signin: self.signin,
authenticate: self.authenticate,
jwt: self.jwt,
})
}

View file

@ -67,6 +67,7 @@ pub(crate) static KEYWORDS: phf::Map<UniCase<&'static str>, TokenKind> = phf_map
UniCase::ascii("ASCII") => TokenKind::Keyword(Keyword::Ascii),
UniCase::ascii("ASSERT") => TokenKind::Keyword(Keyword::Assert),
UniCase::ascii("AT") => TokenKind::Keyword(Keyword::At),
UniCase::ascii("AUTHENTICATE") => TokenKind::Keyword(Keyword::Authenticate),
UniCase::ascii("BEFORE") => TokenKind::Keyword(Keyword::Before),
UniCase::ascii("BEGIN") => TokenKind::Keyword(Keyword::Begin),
UniCase::ascii("BLANK") => TokenKind::Keyword(Keyword::Blank),

View file

@ -302,6 +302,11 @@ impl Parser<'_> {
ac.signin =
Some(stk.run(|stk| self.parse_value(stk)).await?);
}
t!("AUTHENTICATE") => {
self.pop_peek();
ac.authenticate =
Some(stk.run(|stk| self.parse_value(stk)).await?);
}
_ => break,
}
}
@ -515,6 +520,10 @@ impl Parser<'_> {
self.pop_peek();
ac.signin = Some(stk.run(|stk| self.parse_value(stk)).await?);
}
t!("AUTHENTICATE") => {
self.pop_peek();
ac.authenticate = Some(stk.run(|stk| self.parse_value(stk)).await?);
}
_ => break,
}
}

View file

@ -963,11 +963,11 @@ fn parse_define_access_record() {
_ => panic!(),
}
}
// Session duration and signing queries are explicitly defined.
// Session duration, signing and authentication queries are explicitly defined.
{
let res = test_parse!(
parse_stmt,
r#"DEFINE ACCESS a ON DB TYPE RECORD SIGNUP true SIGNIN false DURATION FOR SESSION 7d"#
r#"DEFINE ACCESS a ON DB TYPE RECORD SIGNUP true SIGNIN false AUTHENTICATE true DURATION FOR SESSION 7d"#
)
.unwrap();
@ -993,6 +993,7 @@ fn parse_define_access_record() {
AccessType::Record(ac) => {
assert_eq!(ac.signup, Some(Value::Bool(true)));
assert_eq!(ac.signin, Some(Value::Bool(false)));
assert_eq!(ac.authenticate, Some(Value::Bool(true)));
match ac.jwt.verify {
JwtAccessVerify::Key(key) => {
assert_eq!(key.alg, Algorithm::Hs512);
@ -1024,6 +1025,7 @@ fn parse_define_access_record() {
kind: AccessType::Record(RecordAccess {
signup: None,
signin: None,
authenticate: None,
jwt: JwtAccess {
verify: JwtAccessVerify::Key(JwtAccessVerifyKey {
alg: Algorithm::Hs384,
@ -1061,6 +1063,7 @@ fn parse_define_access_record() {
kind: AccessType::Record(RecordAccess {
signup: None,
signin: None,
authenticate: None,
jwt: JwtAccess {
verify: JwtAccessVerify::Key(JwtAccessVerifyKey {
alg: Algorithm::Ps512,
@ -1097,6 +1100,7 @@ fn parse_define_access_record() {
kind: AccessType::Record(RecordAccess {
signup: None,
signin: None,
authenticate: None,
jwt: JwtAccess {
verify: JwtAccessVerify::Key(JwtAccessVerifyKey {
alg: Algorithm::Rs256,
@ -1145,6 +1149,7 @@ fn parse_define_access_record_with_jwt() {
kind: AccessType::Record(RecordAccess {
signup: None,
signin: None,
authenticate: None,
jwt: JwtAccess {
verify: JwtAccessVerify::Key(JwtAccessVerifyKey {
alg: Algorithm::EdDSA,

View file

@ -200,6 +200,7 @@ fn statements() -> Vec<Statement> {
kind: AccessType::Record(RecordAccess {
signup: None,
signin: None,
authenticate: None,
jwt: JwtAccess {
verify: JwtAccessVerify::Key(JwtAccessVerifyKey {
alg: Algorithm::EdDSA,

View file

@ -35,6 +35,7 @@ keyword! {
Ascii => "ASCII",
Assert => "ASSERT",
At => "AT",
Authenticate => "AUTHENTICATE",
Before => "BEFORE",
Begin => "BEGIN",
Blank => "BLANK",