Compare commits

...

6 commits

Author SHA1 Message Date
fef
d6fc5d373a
add auth endpoint 2022-12-19 15:49:39 +01:00
fef
41b27830b9
add signup endpoint 2022-12-19 15:47:40 +01:00
fef
776cbe6d52
add even more fields to user schema 2022-12-19 15:45:03 +01:00
fef
eccf4810a9
add some documentation for top-level modules 2022-12-19 13:45:37 +01:00
fef
808b03b817
add source to notes schema 2022-12-18 17:38:19 +01:00
fef
44807f408f
include JWT secret in default config 2022-12-18 17:11:51 +01:00
18 changed files with 274 additions and 61 deletions

View file

@ -16,3 +16,7 @@ BIND_PORT=1312
# Database connection string
# Full schema: postgres://user:password@host:port/database
DATABASE_URL=postgres://nyanoblog@localhost/nyanoblog
# HMAC secret for issuing JSON Web Tokens, generate with:
# dd if=/dev/random bs=1M count=1 | sha256sum
JWT_SECRET=

View file

@ -1,12 +1,15 @@
CREATE TABLE notes (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT REFERENCES accounts(id),
account_id BIGINT REFERENCES accounts (id),
uri VARCHAR,
content TEXT NOT NULL,
source TEXT DEFAULT NULL,
summary TEXT DEFAULT NULL,
sensitive BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX index_notes_on_account_id ON notes (account_id);
CREATE UNIQUE INDEX index_notes_on_uri ON notes (uri);
CREATE INDEX index_notes_on_created_at ON notes (created_at);

View file

@ -3,7 +3,9 @@ CREATE TABLE users (
account_id BIGINT REFERENCES accounts (id),
email VARCHAR NOT NULL,
password VARCHAR NOT NULL,
activated BOOLEAN NOT NULL DEFAULT FALSE
reason VARCHAR DEFAULT NULL,
activated BOOLEAN NOT NULL DEFAULT FALSE,
locale VARCHAR NOT NULL
);
CREATE UNIQUE INDEX index_users_on_email ON users (email);

View file

@ -1,5 +1,4 @@
use serde;
use sqlx::{Executor, PgPool};
use sqlx::PgPool;
use crate::core::*;
use crate::model::account::{Account, NewAccount};
@ -30,8 +29,11 @@ impl PgAccountDataSource {
T: Into<NewAccount> + Send,
{
let new = new.into();
let account: Account =
sqlx::query_as("INSERT INTO accounts (name, domain, display_name) VALUES ($1, $2, $3)")
let account: Account = sqlx::query_as(
"INSERT INTO accounts (name, domain, display_name)
VALUES ($1, $2, $3)
RETURNING *",
)
.bind(new.name)
.bind(new.domain)
.bind(new.display_name)

View file

@ -31,9 +31,9 @@ impl PgNoteDataSource {
{
let id: Id = id.into();
let notes: Vec<Note> = sqlx::query_as(
"SELECT * FROM notes \
WHERE account_id = $1 AND created_at < $1 \
ORDER BY created_at \
"SELECT * FROM notes
WHERE account_id = $1 AND created_at < $1
ORDER BY created_at
LIMIT 20",
)
.bind(id)
@ -49,17 +49,20 @@ impl PgNoteDataSource {
{
let new = new.into();
let note: Note = sqlx::query_as(
"INSERT INTO notes ( \
account_id, \
uri, \
content, \
summary, \
sensitive \
) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO notes (
account_id,
uri,
content,
source,
summary,
sensitive
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *",
)
.bind(new.account_id)
.bind(new.uri)
.bind(new.content)
.bind(new.source)
.bind(new.summary)
.bind(new.sensitive)
.fetch_one(&self.pool)

View file

@ -13,11 +13,7 @@ impl PgUserDataSource {
PgUserDataSource { pool }
}
pub async fn by_id<I>(&self, id: I) -> Result<User>
where
I: Into<Id> + Send,
{
let id: Id = id.into();
pub async fn by_id(&self, id: Id) -> Result<User> {
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_one(&self.pool)
@ -25,28 +21,33 @@ impl PgUserDataSource {
Ok(user)
}
pub async fn by_account<I>(&self, account_id: I) -> Result<User>
where
I: Into<Id> + Send,
{
let id: Id = account_id.into();
pub async fn by_account(&self, account_id: Id) -> Result<User> {
let user: User = sqlx::query_as("SELECT * FROM users WHERE account_id = $1")
.bind(id)
.bind(account_id)
.fetch_one(&self.pool)
.await?;
Ok(user)
}
pub async fn create<T>(&self, new: T) -> Result<User>
where
T: Into<NewUser> + Send,
{
let new = new.into();
let user: User =
sqlx::query_as("INSERT INTO users (account_id, email, password) VALUES ($1, $2, $3)")
pub async fn by_email(&self, email: &str) -> Result<User> {
let user: User = sqlx::query_as("SELECT * FROM users WHERE email = $1")
.bind(email)
.fetch_one(&self.pool)
.await?;
Ok(user)
}
pub async fn create(&self, new: NewUser) -> Result<User> {
let user: User = sqlx::query_as(
"INSERT INTO users (account_id, email, password, reason, locale) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING *",
)
.bind(new.account_id)
.bind(new.email)
.bind(new.password)
.bind(new.reason)
.bind(new.locale)
.fetch_one(&self.pool)
.await?;
Ok(user)

View file

@ -3,14 +3,50 @@ use sqlx::postgres::PgPoolOptions;
use sqlx::{migrate, PgPool};
mod conf;
/// Core data types used throughout the entire app.
mod core;
/// Data sources for repositories.
///
/// Data sources are the interfaces between repositories and an underlying
/// storage layer, such as Postgres or Redis. Each data source is only
/// responsible for a single storage engine, and a repository may have multiple
/// data sources of different preferences.
mod data;
/// Asynchronous background workers.
mod job;
/// Database models and validation.
mod model;
/// The central interface between data storage and the rest of the app.
///
/// This module contains all _Repositories_, which are the central interface
/// between the data storage/management code and the rest of the server.
/// They act as a full layer of abstraction to keep the rest of the app
/// completely agnostic to the internals of how storage, including caching,
/// is implemented.
///
/// Any data that the rest of the app needs access to or wishes to store must
/// travel through an interface provided by a repository. This makes them
/// perfect gatekeepers for enforcing validation, which is why APIs that store
/// data will typically be implemented as generics that require an
/// `Into<Sane<T>>` (where `T` is the model structure for that database table).
///
/// Internally, repositories act on several _Data Sources_.
/// See the [`data`] module for more information.
mod repo;
/// HTTP request handlers.
mod route;
mod state;
/// Miscellaneous little helpers.
mod util;
use crate::core::*;
use conf::Config;

View file

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use crate::core::Id;
use crate::util::validate::{ResultBuilder, Validate};
#[derive(Deserialize, Serialize, FromRow)]
pub struct Account {
@ -25,3 +26,18 @@ impl From<Account> for Id {
account.id
}
}
impl Validate for NewAccount {
fn validate(&self, builder: ResultBuilder) -> ResultBuilder {
builder
.field(&self.name, "name", |f| {
f.check("Must not be empty", |v| v.len() > 0)
.check("Must be at most 16 characters long", |v| v.len() <= 16)
})
.field(&self.display_name, "display_name", |f| {
f.check("Must be at most 64 characters long", |v| {
v.as_ref().map(|v| v.len()).unwrap_or(0) <= 64
})
})
}
}

View file

@ -10,6 +10,7 @@ pub struct Note {
pub account_id: Id,
pub uri: Option<String>,
pub content: String,
pub source: Option<String>,
pub summary: Option<String>,
pub sensitive: bool,
pub created_at: NaiveDateTime,
@ -20,6 +21,7 @@ pub struct NewNote {
pub account_id: Id,
pub uri: String,
pub content: String,
pub source: Option<String>,
pub summary: Option<String>,
pub sensitive: bool,
}

View file

@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use crate::core::*;
use crate::util::validate::{ResultBuilder, Validate};
#[derive(Deserialize, Serialize, FromRow)]
pub struct User {
@ -9,12 +10,17 @@ pub struct User {
pub account_id: Id,
pub email: String,
pub password: String,
pub reason: Option<String>,
pub locale: String,
pub activated: bool,
}
pub struct NewUser {
pub account_id: Id,
pub email: String,
pub password: String,
pub reason: Option<String>,
pub locale: String,
}
impl From<User> for Id {
@ -22,3 +28,11 @@ impl From<User> for Id {
user.id
}
}
impl Validate for NewUser {
fn validate(&self, builder: ResultBuilder) -> ResultBuilder {
builder.field(&self.email, "email", |f| {
f.check("Email must not be empty", |v| v.len() > 0)
})
}
}

View file

@ -22,10 +22,13 @@ impl AccountRepo {
self.db.by_id(id).await
}
pub async fn create<T>(&self, new: T) -> Result<Account>
pub async fn create<T, E>(&self, new: T) -> Result<Account>
where
T: Into<NewAccount> + Send,
T: TryInto<Sane<NewAccount>, Error = E> + Send,
E: Into<Error>,
{
self.db.create(new).await
self.db
.create(new.try_into().map_err(|e| e.into())?.inner())
.await
}
}

View file

@ -19,20 +19,30 @@ impl UserRepo {
where
I: Into<Id> + Send,
{
self.db.by_id(id).await
self.db.by_id(id.into()).await
}
pub async fn by_account<I>(&self, account_id: I) -> Result<User>
where
I: Into<Id> + Send,
{
self.db.by_account(account_id).await
self.db.by_account(account_id.into()).await
}
pub async fn create<T>(&self, new: T) -> Result<User>
pub async fn by_email<T>(&self, email: T) -> Result<User>
where
T: Into<NewUser> + Send,
T: AsRef<str>,
{
self.db.create(new).await
self.db.by_email(email.as_ref()).await
}
pub async fn create<T, E>(&self, new: T) -> Result<User>
where
T: TryInto<Sane<NewUser>, Error = E> + Send,
E: Into<Error>,
{
self.db
.create(new.try_into().map_err(|e| e.into())?.inner())
.await
}
}

View file

@ -1,7 +1,9 @@
use actix_web::web;
pub mod nyano;
pub mod v1;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/v1").configure(v1::configure));
cfg.service(web::scope("/v1").configure(v1::configure))
.service(web::scope("/nyano").configure(nyano::configure));
}

View file

@ -0,0 +1,7 @@
use actix_web::web;
pub mod v1;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/v1").configure(v1::configure));
}

View file

@ -0,0 +1,43 @@
use actix_web::{post, web, HttpResponse};
use serde::{Deserialize, Serialize};
use crate::core::*;
use crate::model::Account;
use crate::state::AppState;
use crate::util::{password, token};
#[derive(Deserialize)]
struct AuthRequest {
email: String,
password: String,
}
#[derive(Serialize)]
struct AuthResponse {
token: String,
account: Account,
}
#[post("")]
async fn auth(body: web::Json<AuthRequest>, state: AppState) -> Result<HttpResponse> {
let body = body.into_inner();
// make sure unknown email address returns the same error as invalid password
let user = state
.repo
.users
.by_email(body.email)
.await
.map_err(|e| match e {
Error::NotFound => Error::BadCredentials,
e => e,
})?;
password::verify(&body.password, &user.password).map_err(|_| Error::BadCredentials)?;
let account = state.repo.accounts.by_id(user.account_id).await?;
let token = token::issue(&state, &account)?;
info!(target: "auth", "Successful login for user {}", &account.name);
Ok(HttpResponse::Ok().json(AuthResponse { account, token }))
}
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(auth);
}

View file

@ -0,0 +1,7 @@
use actix_web::web;
pub mod auth;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/auth").configure(auth::configure));
}

View file

@ -1,8 +1,63 @@
use actix_web::{get, web, HttpRequest, HttpResponse, Responder};
use actix_web::{get, post, web, HttpResponse};
use serde::Deserialize;
use crate::core::*;
use crate::model::account::Account;
use crate::model::{NewAccount, NewUser};
use crate::state::AppState;
use crate::util::password;
use crate::util::validate::{ResultBuilder, Validate};
#[derive(Deserialize)]
struct SignupData {
username: String,
email: String,
password: String,
agreement: bool,
locale: String,
reason: Option<String>,
}
#[post("")]
async fn signup(data: web::Json<SignupData>, state: AppState) -> Result<HttpResponse> {
let data: Sane<SignupData> = Insane::from(data.into_inner()).try_into()?;
let data = data.inner();
let account = state
.repo
.accounts
.create(Insane::from(NewAccount {
name: data.username,
domain: "localhost".into(),
display_name: None,
}))
.await?;
state
.repo
.users
.create(Insane::from(NewUser {
account_id: account.id,
email: data.email,
password: password::hash(&data.password),
locale: data.locale,
reason: data.reason,
}))
.await?;
Ok(HttpResponse::Ok().finish())
}
impl Validate for SignupData {
fn validate(&self, builder: ResultBuilder) -> ResultBuilder {
builder
.check(
&self.agreement,
"agreement",
"You must accept the Terms of Service",
|v| *v,
)
.field(&self.password, "password", |f| {
f.check("Must be at least 8 characters long", |v| v.len() >= 8)
})
}
}
#[get("/{id}")]
async fn get_by_id(path: web::Path<Id>, state: AppState) -> Result<HttpResponse> {
@ -20,5 +75,5 @@ async fn get_notes(path: web::Path<Id>, state: AppState) -> Result<HttpResponse>
}
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(get_by_id).service(get_notes);
cfg.service(get_by_id).service(get_notes).service(signup);
}

View file

@ -2,7 +2,9 @@ use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
pub fn hash(clear: String) -> String {
use crate::core::*;
pub fn hash(clear: &String) -> String {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
@ -11,9 +13,10 @@ pub fn hash(clear: String) -> String {
.to_string()
}
pub fn verify(clear: String, hash: String) -> bool {
pub fn verify(clear: &String, hash: &String) -> Result<()> {
let parsed_hash = PasswordHash::new(&hash).unwrap();
Argon2::default()
.verify_password(clear.as_bytes(), &parsed_hash)
.is_ok()
match Argon2::default().verify_password(clear.as_bytes(), &parsed_hash) {
Ok(_) => Ok(()),
Err(_) => Err(Error::BadCredentials),
}
}