diff --git a/gleam.toml b/gleam.toml index 15a736e..5e3548f 100644 --- a/gleam.toml +++ b/gleam.toml @@ -20,6 +20,7 @@ gleam_pgo = ">= 0.13.0 and < 1.0.0" birl = ">= 1.7.1 and < 2.0.0" decode = ">= 0.2.0 and < 1.0.0" gleam_json = ">= 1.0.1 and < 2.0.0" +youid = ">= 1.2.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 9f67f5f..cc58daf 100644 --- a/manifest.toml +++ b/manifest.toml @@ -12,6 +12,7 @@ packages = [ { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, { name = "gleam_javascript", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "483631D3001FCE8EB12ADEAD5E1B808440038E96F93DA7A32D326C82F480C0B2" }, { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, @@ -31,6 +32,7 @@ packages = [ { name = "startest", version = "0.5.0", build_tools = ["gleam"], requirements = ["argv", "bigben", "birl", "exception", "gleam_community_ansi", "gleam_erlang", "gleam_javascript", "gleam_stdlib", "glint", "simplifile", "tom"], otp_app = "startest", source = "hex", outer_checksum = "7A4BE0A1674D1DEB421B0BEB1E90B1A9C4AB21D954CF1754C0E28EA6B5DBE785" }, { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, + { name = "youid", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "youid", source = "hex", outer_checksum = "EF0F693004E221155EE5909C6D3C945DD14F7117DBA882887CF5F45BE399B8CA" }, ] [requirements] @@ -42,3 +44,4 @@ gleam_json = { version = ">= 1.0.1 and < 2.0.0" } gleam_pgo = { version = ">= 0.13.0 and < 1.0.0" } gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +youid = { version = ">= 1.2.0 and < 2.0.0" } diff --git a/src/pevensie/auth.gleam b/src/pevensie/auth.gleam index b55b15b..897e6cc 100644 --- a/src/pevensie/auth.gleam +++ b/src/pevensie/auth.gleam @@ -1,19 +1,24 @@ import argus +import birl.{type Time} +import birl/duration import gleam/dict import gleam/dynamic.{type Decoder} +import gleam/json import gleam/option.{type Option, None, Some} import gleam/result +import pevensie/cache import pevensie/drivers.{ type AuthDriver, type Connected, type Disabled, type Disconnected, - type Encoder, } import pevensie/internal/auth +import pevensie/internal/encoder.{type Encoder} import pevensie/internal/pevensie.{type Pevensie} import pevensie/internal/user.{ type User as InternalUser, type UserInsert as UserInsertInternal, type UserUpdate as UserUpdateInternal, Set, UserInsert, UserUpdate, default_user_update, } +import youid/uuid pub type User(user_metadata) = InternalUser(user_metadata) @@ -76,7 +81,7 @@ fn hash_password(password: String) { |> argus.hash(password, argus.gen_salt()) } -pub fn verify_email_and_password( +pub fn get_user_by_email_and_password( pevensie: Pevensie( user_metadata, auth_driver, @@ -167,3 +172,132 @@ pub fn set_user_role( user_metadata_encoder, ) } + +pub type Session { + Session(id: String, user_id: String, created_at: Time, expires_at: Time) +} + +fn encode_session(session: Session) -> json.Json { + json.object([ + #("id", json.string(session.id)), + #("user_id", json.string(session.user_id)), + #("created_at", json.string(birl.to_iso8601(session.created_at))), + #("expires_at", json.string(birl.to_iso8601(session.expires_at))), + ]) +} + +fn session_decoder() -> Decoder(Session) { + let time_decoder = fn(time) { + use time <- result.try(dynamic.string(time)) + birl.parse(time) + |> result.replace_error([]) + } + + dynamic.decode4( + Session, + dynamic.field("id", dynamic.string), + dynamic.field("user_id", dynamic.string), + dynamic.field("created_at", time_decoder), + dynamic.field("expires_at", time_decoder), + ) +} + +const session_resource_type = "pevensie:session" + +pub fn create_session( + pevensie: Pevensie( + user_metadata, + auth_driver, + Connected, + cache_driver, + Connected, + ), + user_id: String, + ttl_seconds: Option(Int), +) -> Result(Session, Nil) { + // Check if the user exists + use user <- result.try(get_user_by_id(pevensie, user_id)) + + let ttl_seconds = option.unwrap(ttl_seconds, 24 * 60 * 60) + let now = birl.now() + let session = + Session( + id: uuid.v7_string(), + user_id: user.id, + created_at: now, + expires_at: birl.add(now, duration.seconds(ttl_seconds)), + ) + + use _ <- result.try(cache.store( + pevensie, + session_resource_type, + session.id, + session + |> encode_session + |> json.to_string, + Some(ttl_seconds), + )) + + Ok(session) +} + +pub fn get_session( + pevensie: Pevensie( + user_metadata, + auth_driver, + Connected, + cache_driver, + Connected, + ), + session_id: String, +) -> Result(Option(Session), Nil) { + use session <- result.try(cache.get( + pevensie, + session_resource_type, + session_id, + )) + case session { + None -> Ok(None) + Some(session_string) -> { + use decoded_session <- result.try( + json.decode(session_string, session_decoder()) + |> result.replace_error(Nil), + ) + Ok(Some(decoded_session)) + } + } +} + +pub fn delete_session( + pevensie: Pevensie( + user_metadata, + auth_driver, + Connected, + cache_driver, + Connected, + ), + session_id: String, +) -> Result(Nil, Nil) { + use _ <- result.try(cache.delete(pevensie, session_resource_type, session_id)) + Ok(Nil) +} + +pub fn log_in_user( + pevensie: Pevensie( + user_metadata, + auth_driver, + Connected, + cache_driver, + Connected, + ), + email: String, + password: String, +) -> Result(#(Session, User(user_metadata)), Nil) { + use user <- result.try(get_user_by_email_and_password( + pevensie, + email, + password, + )) + create_session(pevensie, user.id, Some(24 * 60 * 60)) + |> result.map(fn(session) { #(session, user) }) +} diff --git a/src/pevensie/drivers.gleam b/src/pevensie/drivers.gleam index 15992a6..047d648 100644 --- a/src/pevensie/drivers.gleam +++ b/src/pevensie/drivers.gleam @@ -1,6 +1,6 @@ import gleam/dynamic.{type Decoder} -import gleam/json import gleam/option.{type Option} +import pevensie/internal/encoder.{type Encoder} import pevensie/internal/user.{type User, type UserInsert, type UserUpdate} pub type Connected @@ -9,9 +9,6 @@ pub type Disconnected pub type Disabled -pub type Encoder(a) = - fn(a) -> json.Json - /// A function that connects the auth driver. This may /// set up any connections or perform any other setup /// required to make the driver ready to use. diff --git a/src/pevensie/drivers/postgres.gleam b/src/pevensie/drivers/postgres.gleam index 8b82582..5492ac8 100644 --- a/src/pevensie/drivers/postgres.gleam +++ b/src/pevensie/drivers/postgres.gleam @@ -15,8 +15,9 @@ import gleam/pgo.{type QueryError as PgoQueryError} import gleam/result import gleam/string import pevensie/drivers.{ - type AuthDriver, type CacheDriver, type Encoder, AuthDriver, CacheDriver, + type AuthDriver, type CacheDriver, AuthDriver, CacheDriver, } +import pevensie/internal/encoder.{type Encoder} import pevensie/internal/user.{ type UpdateField, type User, type UserInsert, type UserUpdate, Ignore, Set, User, app_metadata_to_json, @@ -266,20 +267,20 @@ fn postgres_user_decoder( } Ok(User( - id, - created_at, - updated_at, - deleted_at, - role, - email, - password_hash, - email_confirmed_at, - phone_number, - phone_number_confirmed_at, - last_sign_in, - app_metadata, - user_metadata, - banned_until, + id:, + created_at:, + updated_at:, + deleted_at:, + role:, + email:, + password_hash:, + email_confirmed_at:, + phone_number:, + phone_number_confirmed_at:, + last_sign_in:, + app_metadata:, + user_metadata:, + banned_until:, )) }) |> decode.field(0, decode.string) diff --git a/src/pevensie/internal/auth.gleam b/src/pevensie/internal/auth.gleam index 9784736..79506dd 100644 --- a/src/pevensie/internal/auth.gleam +++ b/src/pevensie/internal/auth.gleam @@ -1,5 +1,6 @@ import gleam/dynamic.{type Decoder} -import pevensie/drivers.{type AuthDriver, type Encoder} +import pevensie/drivers.{type AuthDriver} +import pevensie/internal/encoder.{type Encoder} pub type AuthConfig(driver, user_metadata, connected) { AuthConfig( diff --git a/src/pevensie/internal/encoder.gleam b/src/pevensie/internal/encoder.gleam new file mode 100644 index 0000000..aef0d88 --- /dev/null +++ b/src/pevensie/internal/encoder.gleam @@ -0,0 +1,4 @@ +import gleam/json + +pub type Encoder(a) = + fn(a) -> json.Json diff --git a/src/pevensie/internal/user.gleam b/src/pevensie/internal/user.gleam index d427684..4d35ab9 100644 --- a/src/pevensie/internal/user.gleam +++ b/src/pevensie/internal/user.gleam @@ -3,6 +3,7 @@ import gleam/dict.{type Dict} import gleam/dynamic.{type Dynamic} import gleam/json import gleam/option.{type Option} +import pevensie/internal/encoder.{type Encoder} pub type User(user_metadata) { User( @@ -14,7 +15,7 @@ pub type User(user_metadata) { email: String, password_hash: Option(String), email_confirmed_at: Option(Time), - phome_number: Option(String), + phone_number: Option(String), phone_number_confirmed_at: Option(Time), last_sign_in: Option(Time), app_metadata: Dict(String, Dynamic), @@ -74,3 +75,52 @@ pub fn app_metadata_to_json(_app_metadata: Dict(String, Dynamic)) -> json.Json { // TODO: Properly type app_metadata json.object([]) } + +pub fn user_encoder( + user: User(user_metadata), + user_metadata_encoder: Encoder(user_metadata), +) -> json.Json { + json.object([ + #("id", json.string(user.id)), + #("created_at", json.string(birl.to_iso8601(user.created_at))), + #("updated_at", json.string(birl.to_iso8601(user.updated_at))), + #( + "deleted_at", + json.nullable(user.deleted_at |> option.map(birl.to_iso8601), json.string), + ), + #("role", json.nullable(user.role, json.string)), + #("email", json.string(user.email)), + #("password_hash", json.nullable(user.password_hash, json.string)), + #( + "email_confirmed_at", + json.nullable( + user.email_confirmed_at |> option.map(birl.to_iso8601), + json.string, + ), + ), + #("phone_number", json.nullable(user.phone_number, json.string)), + #( + "phone_number_confirmed_at", + json.nullable( + user.phone_number_confirmed_at |> option.map(birl.to_iso8601), + json.string, + ), + ), + #( + "last_sign_in", + json.nullable( + user.last_sign_in |> option.map(birl.to_iso8601), + json.string, + ), + ), + #("app_metadata", app_metadata_to_json(user.app_metadata)), + #("user_metadata", user_metadata_encoder(user.user_metadata)), + #( + "banned_until", + json.nullable( + user.banned_until |> option.map(birl.to_iso8601), + json.string, + ), + ), + ]) +}