WebAuthN auth
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ npm-debug.log
|
|||||||
/.direnv
|
/.direnv
|
||||||
/.nix-mix
|
/.nix-mix
|
||||||
/.nix-hex
|
/.nix-hex
|
||||||
|
/.elixir_ls
|
||||||
|
|||||||
@@ -22,8 +22,102 @@ import {Socket} from "phoenix"
|
|||||||
import {LiveSocket} from "phoenix_live_view"
|
import {LiveSocket} from "phoenix_live_view"
|
||||||
import topbar from "../vendor/topbar"
|
import topbar from "../vendor/topbar"
|
||||||
|
|
||||||
|
function base64ToArray(base64String) {
|
||||||
|
return Uint8Array.from(window.atob(base64String), (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayToBase64(buffer) {
|
||||||
|
return window.btoa(
|
||||||
|
Array.from(new Uint8Array(buffer), (c) => String.fromCharCode(c)).join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const registrationHook = {
|
||||||
|
mounted() {
|
||||||
|
this.handleEvent("registration-challenge", (event) => this.handleRegistration(event, this))
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleRegistration(event, context) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
attestation,
|
||||||
|
challenge,
|
||||||
|
rp,
|
||||||
|
user,
|
||||||
|
timeout,
|
||||||
|
excludeCredentials,
|
||||||
|
} = event;
|
||||||
|
user.id = base64ToArray(user.id)
|
||||||
|
excludeCredentials.forEach(cred => {
|
||||||
|
cred.id = base64ToArray(cred.id)
|
||||||
|
})
|
||||||
|
const publicKey = {
|
||||||
|
attestation,
|
||||||
|
challenge: base64ToArray(challenge),
|
||||||
|
excludeCredentials,
|
||||||
|
pubKeyCredParams: [{ alg: -7, type: "public-key" }, { alg: -8, type: "public-key" }, { alg: -257, type: "public-key"}],
|
||||||
|
authenticatorSelection: {
|
||||||
|
authenticatorAttachement: "explicitly invalid, working around bitwarden",
|
||||||
|
residentKey: "discouraged"
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
timeout,
|
||||||
|
rp,
|
||||||
|
}
|
||||||
|
const credential = await navigator.credentials.create({ publicKey })
|
||||||
|
context.pushEventTo(context.el, "registration-complete", {
|
||||||
|
attestation64: arrayToBase64(credential.response.attestationObject),
|
||||||
|
clientData: Array.from(new Uint8Array(credential.response.clientDataJSON)),
|
||||||
|
id: arrayToBase64(credential.rawId),
|
||||||
|
type: credential.type
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
const { message, name, stack } = error;
|
||||||
|
context.pushEventTo(context.el, "error", { message, name, stack });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticationHook = {
|
||||||
|
mounted() {
|
||||||
|
this.handleEvent("authentication-challenge", (event) => this.handleAuthentication(event, this))
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleAuthentication(event, context) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
challenge, allowCredentials
|
||||||
|
} = event;
|
||||||
|
allowCredentials.forEach(cred => {
|
||||||
|
cred.id = base64ToArray(cred.id)
|
||||||
|
})
|
||||||
|
const { type, response: { signature, authenticatorData, clientDataJSON, userHandle }, rawId } = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
challenge: base64ToArray(challenge),
|
||||||
|
allowCredentials,
|
||||||
|
timeout: 60000
|
||||||
|
}
|
||||||
|
})
|
||||||
|
context.pushEventTo(context.el, "authentication-credential", {
|
||||||
|
type: type,
|
||||||
|
id: arrayToBase64(rawId),
|
||||||
|
signature: arrayToBase64(signature),
|
||||||
|
authenticatorData: arrayToBase64(authenticatorData),
|
||||||
|
clientData: Array.from(new Uint8Array(clientDataJSON)),
|
||||||
|
userHandle: arrayToBase64(userHandle)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
const { message, name, stack } = error;
|
||||||
|
context.pushEventTo(context.el, "error", { message, name, stack });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
|
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: { registrationHook, authenticationHook }})
|
||||||
|
|
||||||
// Show progress bar on live navigation and form submits
|
// Show progress bar on live navigation and form submits
|
||||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ module.exports = {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: "#FD4F00",
|
brand: "#FD4F00",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ["monospace"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,15 +22,6 @@ config :sso_bsn, SsoBsnWeb.Endpoint,
|
|||||||
pubsub_server: SsoBsn.PubSub,
|
pubsub_server: SsoBsn.PubSub,
|
||||||
live_view: [signing_salt: "sMW627iF"]
|
live_view: [signing_salt: "sMW627iF"]
|
||||||
|
|
||||||
# Configures the mailer
|
|
||||||
#
|
|
||||||
# By default it uses the "Local" adapter which stores the emails
|
|
||||||
# locally. You can see the emails in your browser, at "/dev/mailbox".
|
|
||||||
#
|
|
||||||
# For production it's recommended to configure a different adapter
|
|
||||||
# at the `config/runtime.exs`.
|
|
||||||
config :sso_bsn, SsoBsn.Mailer, adapter: Swoosh.Adapters.Local
|
|
||||||
|
|
||||||
# Configure esbuild (the version is required)
|
# Configure esbuild (the version is required)
|
||||||
config :esbuild,
|
config :esbuild,
|
||||||
version: "0.17.11",
|
version: "0.17.11",
|
||||||
|
|||||||
@@ -75,5 +75,4 @@ config :phoenix, :plug_init_mode, :runtime
|
|||||||
# Include HEEx debug annotations as HTML comments in rendered markup
|
# Include HEEx debug annotations as HTML comments in rendered markup
|
||||||
config :phoenix_live_view, :debug_heex_annotations, true
|
config :phoenix_live_view, :debug_heex_annotations, true
|
||||||
|
|
||||||
# Disable swoosh api client as it is only required for production adapters.
|
config :wax_, origin: "http://localhost:4000", rp_id: :auto
|
||||||
config :swoosh, :api_client, false
|
|
||||||
@@ -7,12 +7,6 @@ import Config
|
|||||||
# before starting your production server.
|
# before starting your production server.
|
||||||
config :sso_bsn, SsoBsnWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
config :sso_bsn, SsoBsnWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||||
|
|
||||||
# Configures Swoosh API Client
|
|
||||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: SsoBsn.Finch
|
|
||||||
|
|
||||||
# Disable Swoosh Local Memory Storage
|
|
||||||
config :swoosh, local: false
|
|
||||||
|
|
||||||
# Do not print debug messages in production
|
# Do not print debug messages in production
|
||||||
config :logger, level: :info
|
config :logger, level: :info
|
||||||
|
|
||||||
|
|||||||
@@ -93,21 +93,4 @@ if config_env() == :prod do
|
|||||||
#
|
#
|
||||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||||
|
|
||||||
# ## Configuring the mailer
|
|
||||||
#
|
|
||||||
# In production you need to configure the mailer to use a different adapter.
|
|
||||||
# Also, you may need to configure the Swoosh API client of your choice if you
|
|
||||||
# are not using SMTP. Here is an example of the configuration:
|
|
||||||
#
|
|
||||||
# config :sso_bsn, SsoBsn.Mailer,
|
|
||||||
# adapter: Swoosh.Adapters.Mailgun,
|
|
||||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
|
||||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
|
||||||
#
|
|
||||||
# For this example you need include a HTTP client required by Swoosh API client.
|
|
||||||
# Swoosh supports Hackney and Finch out of the box:
|
|
||||||
#
|
|
||||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
|
||||||
#
|
|
||||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import Config
|
|
||||||
|
|
||||||
# Configure your database
|
|
||||||
#
|
|
||||||
# The MIX_TEST_PARTITION environment variable can be used
|
|
||||||
# to provide built-in test partitioning in CI environment.
|
|
||||||
# Run `mix help test` for more information.
|
|
||||||
config :sso_bsn, SsoBsn.Repo,
|
|
||||||
database: Path.expand("../sso_bsn_test.db", Path.dirname(__ENV__.file)),
|
|
||||||
pool_size: 5,
|
|
||||||
pool: Ecto.Adapters.SQL.Sandbox
|
|
||||||
|
|
||||||
# We don't run a server during test. If one is required,
|
|
||||||
# you can enable the server option below.
|
|
||||||
config :sso_bsn, SsoBsnWeb.Endpoint,
|
|
||||||
http: [ip: {127, 0, 0, 1}, port: 4002],
|
|
||||||
secret_key_base: "whk08+CMpyhXr7IdWuwHJXMq/y/hwMY10kThociBvOyaKrbMlCc554iSThQj16cs",
|
|
||||||
server: false
|
|
||||||
|
|
||||||
# In test we don't send emails.
|
|
||||||
config :sso_bsn, SsoBsn.Mailer, adapter: Swoosh.Adapters.Test
|
|
||||||
|
|
||||||
# Disable swoosh api client as it is only required for production adapters.
|
|
||||||
config :swoosh, :api_client, false
|
|
||||||
|
|
||||||
# Print only warnings and errors during test
|
|
||||||
config :logger, level: :warning
|
|
||||||
|
|
||||||
# Initialize plugs at runtime for faster test compilation
|
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
|
||||||
1
config/test.exs
Symbolic link
1
config/test.exs
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
./dev.exs
|
||||||
233
lib/sso_bsn/accounts.ex
Normal file
233
lib/sso_bsn/accounts.ex
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
defmodule SsoBsn.Accounts do
|
||||||
|
@moduledoc """
|
||||||
|
The Accounts context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias SsoBsn.Repo
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts.{User, UserToken, UserKey}
|
||||||
|
|
||||||
|
## Database getters
|
||||||
|
|
||||||
|
def reload_user(user), do: Repo.reload(user) |> Repo.preload(:keys)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a user by username.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_user_by_username("foo")
|
||||||
|
%User{}
|
||||||
|
|
||||||
|
iex> get_user_by_username("unknown")
|
||||||
|
nil
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_user_by_username(username) when is_binary(username) do
|
||||||
|
Repo.get_by(User, username: username) |> Repo.preload(:keys)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single user.
|
||||||
|
|
||||||
|
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> get_user!(123)
|
||||||
|
%User{}
|
||||||
|
|
||||||
|
iex> get_user!(456)
|
||||||
|
** (Ecto.NoResultsError)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def get_user!(id), do: Repo.get!(User, id) |> Repo.preload(:keys)
|
||||||
|
|
||||||
|
## User registration
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Registers a user.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> register_user(%{field: value})
|
||||||
|
{:ok, %User{}}
|
||||||
|
|
||||||
|
iex> register_user(%{field: bad_value})
|
||||||
|
{:error, %Ecto.Changeset{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def register_user(attrs) do
|
||||||
|
%User{}
|
||||||
|
|> User.registration_changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking user changes.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> change_user_registration(user)
|
||||||
|
%Ecto.Changeset{data: %User{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def change_user_registration(%User{} = user, attrs \\ %{}) do
|
||||||
|
User.registration_changeset(user, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for changing the user username.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> change_user_username(user)
|
||||||
|
%Ecto.Changeset{data: %User{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def change_user_username(user, attrs \\ %{}) do
|
||||||
|
User.username_changeset(user, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
def authentication_challenge(username \\ nil) do
|
||||||
|
allowed_credentials =
|
||||||
|
if username && String.trim(username) != "",
|
||||||
|
do:
|
||||||
|
get_user_by_username(username).keys |> Enum.map(&%{id: &1.key_id, type: "public-key"}),
|
||||||
|
else: []
|
||||||
|
|
||||||
|
challenge = Wax.new_authentication_challenge(allowed_credentials: allowed_credentials)
|
||||||
|
|
||||||
|
{{username, challenge},
|
||||||
|
%{
|
||||||
|
challenge: Base.encode64(challenge.bytes, padding: false),
|
||||||
|
allowCredentials: allowed_credentials
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_user({username, challenge}, %{
|
||||||
|
"clientData" => client_data_json,
|
||||||
|
"authenticatorData" => authenticator_data_b64,
|
||||||
|
"signature" => sig_b64,
|
||||||
|
"id" => credential_id,
|
||||||
|
"type" => "public-key",
|
||||||
|
"userHandle" => maybe_user_handle_b64
|
||||||
|
}) do
|
||||||
|
authenticator_data_raw = Base.decode64!(authenticator_data_b64)
|
||||||
|
sig_raw = Base.decode64!(sig_b64)
|
||||||
|
|
||||||
|
user = case Base.decode64(maybe_user_handle_b64) do
|
||||||
|
{:ok, <<user_id::64>>} -> get_user!(user_id)
|
||||||
|
_ ->get_user_by_username(username)
|
||||||
|
end
|
||||||
|
|
||||||
|
case Wax.authenticate(
|
||||||
|
credential_id,
|
||||||
|
authenticator_data_raw,
|
||||||
|
sig_raw,
|
||||||
|
client_data_json,
|
||||||
|
challenge,
|
||||||
|
user.keys
|
||||||
|
|> Enum.map(&{&1.key_id, &1.cose_key})
|
||||||
|
) do
|
||||||
|
{:ok, _result} -> {:ok, user}
|
||||||
|
{:error, error} -> {:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Begin registration of a new webauthn key.
|
||||||
|
"""
|
||||||
|
def registration_challenge(user) do
|
||||||
|
challenge = Wax.new_registration_challenge()
|
||||||
|
|
||||||
|
{challenge,
|
||||||
|
%{
|
||||||
|
attestation: "none",
|
||||||
|
challenge: Base.encode64(challenge.bytes, padding: false),
|
||||||
|
excludeCredentials:
|
||||||
|
(user |> Repo.preload([:keys])).keys |> Enum.map(&%{id: &1.key_id, type: "public-key"}),
|
||||||
|
rp: %{
|
||||||
|
id: challenge.rp_id,
|
||||||
|
name: "BSN SSO"
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
user: %{
|
||||||
|
id: Base.encode64(<<user.id::64>>, padding: false),
|
||||||
|
name: user.username,
|
||||||
|
displayName: user.username
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def register_key(user, challenge, %{
|
||||||
|
"attestation64" => attestation_64,
|
||||||
|
"clientData" => client_data,
|
||||||
|
"id" => id,
|
||||||
|
"type" => "public-key"
|
||||||
|
}) do
|
||||||
|
attestation = Base.decode64!(attestation_64, padding: false)
|
||||||
|
|
||||||
|
case Wax.register(attestation, client_data, challenge) do
|
||||||
|
{:ok, {authenticator_data, _result}} ->
|
||||||
|
user
|
||||||
|
|> User.add_key_changeset()
|
||||||
|
|> UserKey.new(%{
|
||||||
|
key_id: id,
|
||||||
|
cose_key: authenticator_data.attested_credential_data.credential_public_key
|
||||||
|
})
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
{:error, e} ->
|
||||||
|
{:error, e}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates a session token.
|
||||||
|
"""
|
||||||
|
def generate_user_session_token(user) do
|
||||||
|
{token, user_token} = UserToken.build_session_token(user)
|
||||||
|
Repo.insert!(user_token)
|
||||||
|
token
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the user with the given signed token.
|
||||||
|
"""
|
||||||
|
def get_user_by_session_token(token) do
|
||||||
|
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||||
|
Repo.one(query) |> Repo.preload(:keys)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes the signed token with the given context.
|
||||||
|
"""
|
||||||
|
def delete_user_session_token(token) do
|
||||||
|
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_user_login_token(user) do
|
||||||
|
{token, user_token} = UserToken.build_login_token(user)
|
||||||
|
Repo.insert!(user_token)
|
||||||
|
token
|
||||||
|
end
|
||||||
|
|
||||||
|
def consume_login_token(login_token) do
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
user_token = UserToken.token_and_context_query(login_token, "login") |> Repo.one() |> Repo.preload(:user)
|
||||||
|
Repo.delete!(user_token)
|
||||||
|
user_token.user
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
60
lib/sso_bsn/accounts/user.ex
Normal file
60
lib/sso_bsn/accounts/user.ex
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
defmodule SsoBsn.Accounts.User do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts.UserKey
|
||||||
|
|
||||||
|
schema "users" do
|
||||||
|
field :username, :string
|
||||||
|
field :confirmed_at, :naive_datetime
|
||||||
|
|
||||||
|
has_many :keys, UserKey
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A user changeset for registration.
|
||||||
|
"""
|
||||||
|
def registration_changeset(user, attrs, _opts \\ []) do
|
||||||
|
user
|
||||||
|
|> cast(attrs, [:username])
|
||||||
|
|> validate_username()
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_username(changeset) do
|
||||||
|
changeset
|
||||||
|
|> validate_format(:username, ~r/^[a-zA-Z_0-9.-]+$/)
|
||||||
|
|> validate_length(:username, min: 3, max: 128)
|
||||||
|
|> unique_constraint(:username)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A user changeset for changing the username.
|
||||||
|
|
||||||
|
It requires the username to change otherwise an error is added.
|
||||||
|
"""
|
||||||
|
def username_changeset(user, attrs, _opts \\ []) do
|
||||||
|
user
|
||||||
|
|> cast(attrs, [:username])
|
||||||
|
|> validate_username()
|
||||||
|
|> case do
|
||||||
|
%{changes: %{username: _}} = changeset -> changeset
|
||||||
|
%{} = changeset -> add_error(changeset, :username, "did not change")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Confirms the account by setting `confirmed_at`.
|
||||||
|
"""
|
||||||
|
def confirm_changeset(user) do
|
||||||
|
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||||
|
change(user, confirmed_at: now)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def add_key_changeset(user) do
|
||||||
|
user
|
||||||
|
|> Ecto.build_assoc(:keys)
|
||||||
|
end
|
||||||
|
end
|
||||||
30
lib/sso_bsn/accounts/user_key.ex
Normal file
30
lib/sso_bsn/accounts/user_key.ex
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
defmodule SsoBsn.Accounts.UserKey do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts.User
|
||||||
|
|
||||||
|
defmodule BinaryTerm do
|
||||||
|
use Ecto.Type
|
||||||
|
def type, do: :binary
|
||||||
|
|
||||||
|
def cast(term), do: {:ok, term}
|
||||||
|
def load(data) when is_binary(data), do: {:ok, :erlang.binary_to_term(data)}
|
||||||
|
def dump(term), do: {:ok, :erlang.term_to_binary(term)} |> dbg()
|
||||||
|
end
|
||||||
|
alias SsoBsn.Accounts.UserKey.BinaryTerm
|
||||||
|
|
||||||
|
schema "users_keys" do
|
||||||
|
field :key_id, :string
|
||||||
|
field :cose_key, BinaryTerm
|
||||||
|
|
||||||
|
belongs_to :user, User
|
||||||
|
end
|
||||||
|
|
||||||
|
def new(key, attrs) do
|
||||||
|
key
|
||||||
|
|> cast(attrs, [:key_id, :cose_key])
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
83
lib/sso_bsn/accounts/user_token.ex
Normal file
83
lib/sso_bsn/accounts/user_token.ex
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
defmodule SsoBsn.Accounts.UserToken do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Query
|
||||||
|
alias SsoBsn.Accounts.UserToken
|
||||||
|
|
||||||
|
@rand_size 32
|
||||||
|
|
||||||
|
@session_validity_in_days 60
|
||||||
|
|
||||||
|
schema "users_tokens" do
|
||||||
|
field :token, :binary
|
||||||
|
field :context, :string
|
||||||
|
field :sent_to, :string
|
||||||
|
belongs_to :user, SsoBsn.Accounts.User
|
||||||
|
|
||||||
|
timestamps(updated_at: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Generates a token that will be stored in a signed place,
|
||||||
|
such as session or cookie. As they are signed, those
|
||||||
|
tokens do not need to be hashed.
|
||||||
|
|
||||||
|
The reason why we store session tokens in the database, even
|
||||||
|
though Phoenix already provides a session cookie, is because
|
||||||
|
Phoenix' default session cookies are not persisted, they are
|
||||||
|
simply signed and potentially encrypted. This means they are
|
||||||
|
valid indefinitely, unless you change the signing/encryption
|
||||||
|
salt.
|
||||||
|
|
||||||
|
Therefore, storing them allows individual user
|
||||||
|
sessions to be expired. The token system can also be extended
|
||||||
|
to store additional data, such as the device used for logging in.
|
||||||
|
You could then use this information to display all valid sessions
|
||||||
|
and devices in the UI and allow users to explicitly expire any
|
||||||
|
session they deem invalid.
|
||||||
|
"""
|
||||||
|
def build_session_token(user) do
|
||||||
|
token = :crypto.strong_rand_bytes(@rand_size)
|
||||||
|
{token, %UserToken{token: token, context: "session", user_id: user.id}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_login_token(user) do
|
||||||
|
token = Base.encode64(:crypto.strong_rand_bytes(@rand_size))
|
||||||
|
{token, %UserToken{token: token, context: "login", user_id: user.id}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if the token is valid and returns its underlying lookup query.
|
||||||
|
|
||||||
|
The query returns the user found by the token, if any.
|
||||||
|
|
||||||
|
The token is valid if it matches the value in the database and it has
|
||||||
|
not expired (after @session_validity_in_days).
|
||||||
|
"""
|
||||||
|
def verify_session_token_query(token) do
|
||||||
|
query =
|
||||||
|
from token in token_and_context_query(token, "session"),
|
||||||
|
join: user in assoc(token, :user),
|
||||||
|
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||||
|
select: user
|
||||||
|
|
||||||
|
{:ok, query}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the token struct for the given token value and context.
|
||||||
|
"""
|
||||||
|
def token_and_context_query(token, context) do
|
||||||
|
from UserToken, where: [token: ^token, context: ^context]
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets all tokens for the given user for the given contexts.
|
||||||
|
"""
|
||||||
|
def user_and_contexts_query(user, :all) do
|
||||||
|
from t in UserToken, where: t.user_id == ^user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_and_contexts_query(user, [_ | _] = contexts) do
|
||||||
|
from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,12 +11,9 @@ defmodule SsoBsn.Application do
|
|||||||
SsoBsnWeb.Telemetry,
|
SsoBsnWeb.Telemetry,
|
||||||
SsoBsn.Repo,
|
SsoBsn.Repo,
|
||||||
{Ecto.Migrator,
|
{Ecto.Migrator,
|
||||||
repos: Application.fetch_env!(:sso_bsn, :ecto_repos),
|
repos: Application.fetch_env!(:sso_bsn, :ecto_repos), skip: skip_migrations?()},
|
||||||
skip: skip_migrations?()},
|
|
||||||
{DNSCluster, query: Application.get_env(:sso_bsn, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:sso_bsn, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: SsoBsn.PubSub},
|
{Phoenix.PubSub, name: SsoBsn.PubSub},
|
||||||
# Start the Finch HTTP client for sending emails
|
|
||||||
{Finch, name: SsoBsn.Finch},
|
|
||||||
# Start a worker by calling: SsoBsn.Worker.start_link(arg)
|
# Start a worker by calling: SsoBsn.Worker.start_link(arg)
|
||||||
# {SsoBsn.Worker, arg},
|
# {SsoBsn.Worker, arg},
|
||||||
# Start to serve requests, typically the last entry
|
# Start to serve requests, typically the last entry
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
defmodule SsoBsn.Mailer do
|
|
||||||
use Swoosh.Mailer, otp_app: :sso_bsn
|
|
||||||
end
|
|
||||||
@@ -115,7 +115,7 @@ defmodule SsoBsnWeb.CoreComponents do
|
|||||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||||
role="alert"
|
role="alert"
|
||||||
class={[
|
class={[
|
||||||
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
|
"fixed bottom-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
|
||||||
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
|
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
|
||||||
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
||||||
]}
|
]}
|
||||||
@@ -147,8 +147,8 @@ defmodule SsoBsnWeb.CoreComponents do
|
|||||||
def flash_group(assigns) do
|
def flash_group(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id}>
|
<div id={@id}>
|
||||||
<.flash kind={:info} title="Success!" flash={@flash} />
|
<.flash kind={:info} title="Success!" flash={@flash} id={"#{@id}-info"} />
|
||||||
<.flash kind={:error} title="Error!" flash={@flash} />
|
<.flash kind={:error} title="Error!" flash={@flash} id={"#{@id}-error"} />
|
||||||
<.flash
|
<.flash
|
||||||
id="client-error"
|
id="client-error"
|
||||||
kind={:error}
|
kind={:error}
|
||||||
|
|||||||
@@ -1,29 +1,3 @@
|
|||||||
<header class="px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<a href="/">
|
|
||||||
<img src={~p"/images/logo.svg"} width="36" />
|
|
||||||
</a>
|
|
||||||
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
|
|
||||||
v<%= Application.spec(:phoenix, :vsn) %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
|
|
||||||
<a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700">
|
|
||||||
@elixirphoenix
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://hexdocs.pm/phoenix/overview.html"
|
|
||||||
class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80"
|
|
||||||
>
|
|
||||||
Get Started <span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<.flash_group flash={@flash} />
|
<.flash_group flash={@flash} />
|
||||||
|
|||||||
@@ -12,6 +12,47 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white antialiased">
|
<body class="bg-white antialiased">
|
||||||
|
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
|
||||||
|
<%= if @current_user do %>
|
||||||
|
<li class="text-[0.8125rem] leading-6 text-zinc-900">
|
||||||
|
<%= @current_user.username %>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link
|
||||||
|
href={~p"/users/settings"}
|
||||||
|
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link
|
||||||
|
href={~p"/users/log_out"}
|
||||||
|
method="delete"
|
||||||
|
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
|
<% else %>
|
||||||
|
<li>
|
||||||
|
<.link
|
||||||
|
href={~p"/users/register"}
|
||||||
|
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link
|
||||||
|
href={~p"/users/log_in"}
|
||||||
|
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,222 +1 @@
|
|||||||
<.flash_group flash={@flash} />
|
<.flash_group flash={@flash} />
|
||||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 1480 957"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="absolute inset-0 h-full w-full"
|
|
||||||
preserveAspectRatio="xMinYMid slice"
|
|
||||||
>
|
|
||||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
|
||||||
<path
|
|
||||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
|
||||||
fill="#FF9F92"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
|
||||||
fill="#FA8372"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
|
||||||
fill="#E96856"
|
|
||||||
fill-opacity=".6"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
|
||||||
fill="#C42652"
|
|
||||||
fill-opacity=".2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
|
||||||
fill="#A41C42"
|
|
||||||
fill-opacity=".2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
|
||||||
fill="#A41C42"
|
|
||||||
fill-opacity=".2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
|
||||||
<div class="mx-auto max-w-xl lg:mx-0">
|
|
||||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
|
||||||
fill="#FD4F00"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
|
|
||||||
Phoenix Framework
|
|
||||||
<small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
|
|
||||||
v<%= Application.spec(:phoenix, :vsn) %>
|
|
||||||
</small>
|
|
||||||
</h1>
|
|
||||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900">
|
|
||||||
Peace of mind from prototype to production.
|
|
||||||
</p>
|
|
||||||
<p class="mt-4 text-base leading-7 text-zinc-600">
|
|
||||||
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
|
|
||||||
</p>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="w-full sm:w-auto">
|
|
||||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
|
||||||
<a
|
|
||||||
href="https://hexdocs.pm/phoenix/overview.html"
|
|
||||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
|
||||||
>
|
|
||||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
|
||||||
</span>
|
|
||||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
|
||||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
|
|
||||||
<path
|
|
||||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
|
||||||
stroke="#18181B"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Guides & Docs
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/phoenixframework/phoenix"
|
|
||||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
|
||||||
>
|
|
||||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
|
||||||
</span>
|
|
||||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
|
||||||
fill="#18181B"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Source Code
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
|
||||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
|
||||||
>
|
|
||||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
|
||||||
</span>
|
|
||||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
|
||||||
<path
|
|
||||||
d="M12 1v6M12 17v6"
|
|
||||||
stroke="#18181B"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="4"
|
|
||||||
fill="#18181B"
|
|
||||||
fill-opacity=".15"
|
|
||||||
stroke="#18181B"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Changelog
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/elixirphoenix"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
|
||||||
>
|
|
||||||
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
|
|
||||||
</svg>
|
|
||||||
Follow on Twitter
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://elixirforum.com"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
|
||||||
>
|
|
||||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
|
||||||
</svg>
|
|
||||||
Discuss on the Elixir Forum
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://web.libera.chat/#elixir"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Chat on Libera IRC
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/elixir"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
|
||||||
>
|
|
||||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
|
||||||
</svg>
|
|
||||||
Join our Discord server
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://fly.io/docs/elixir/getting-started/"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
|
||||||
>
|
|
||||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
|
||||||
</svg>
|
|
||||||
Deploy your application
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
26
lib/sso_bsn_web/controllers/user_session_controller.ex
Normal file
26
lib/sso_bsn_web/controllers/user_session_controller.ex
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
defmodule SsoBsnWeb.UserSessionController do
|
||||||
|
use SsoBsnWeb, :controller
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts
|
||||||
|
alias SsoBsnWeb.UserAuth
|
||||||
|
|
||||||
|
def login(conn, %{"token" => login_token}) do
|
||||||
|
case Accounts.consume_login_token(login_token) do
|
||||||
|
{:ok, user} ->
|
||||||
|
conn
|
||||||
|
|> UserAuth.log_in_user(user) # TODO: pass through remember-me value?
|
||||||
|
|> redirect(to: ~p"/users/settings")
|
||||||
|
{:error, error} ->
|
||||||
|
dbg(error)
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Invalid login token: #{inspect(error)}")
|
||||||
|
|> redirect(to: ~p"/users/log_in")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Logged out successfully.")
|
||||||
|
|> UserAuth.log_out_user()
|
||||||
|
end
|
||||||
|
end
|
||||||
65
lib/sso_bsn_web/live/user_login_live.ex
Normal file
65
lib/sso_bsn_web/live/user_login_live.ex
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
defmodule SsoBsnWeb.UserLoginLive do
|
||||||
|
use SsoBsnWeb, :live_view
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts
|
||||||
|
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-sm">
|
||||||
|
<.header class="text-center">
|
||||||
|
Sign in to account
|
||||||
|
<:subtitle>
|
||||||
|
Don't have an account?
|
||||||
|
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
|
||||||
|
Sign up
|
||||||
|
</.link>
|
||||||
|
for an account now.
|
||||||
|
</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<.simple_form
|
||||||
|
for={@form}
|
||||||
|
id="login_form"
|
||||||
|
action={~p"/users/log_in"}
|
||||||
|
phx-update="ignore"
|
||||||
|
phx-submit="login"
|
||||||
|
phx-hook="authenticationHook"
|
||||||
|
>
|
||||||
|
<.input field={@form[:username]} type="text" label="Username" />
|
||||||
|
|
||||||
|
<:actions>
|
||||||
|
<.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
|
||||||
|
</:actions>
|
||||||
|
<:actions>
|
||||||
|
<.button phx-disable-with="Signing in..." class="w-full" disabled={@authenticating}>
|
||||||
|
Sign in <span aria-hidden="true">→</span>
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.simple_form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket |> assign(form: to_form(%{"username" => "", "remember_me" => false}), authenticating: false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("login", %{"username" => username}, socket) do
|
||||||
|
{challenge, challenge_client} = Accounts.authentication_challenge(username)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(challenge: challenge, authenticating: true)
|
||||||
|
|> push_event("authentication-challenge", challenge_client)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("authentication-credential", params, socket) do
|
||||||
|
case Accounts.authenticate_user(socket.assigns.challenge, params) do
|
||||||
|
{:ok, user} ->
|
||||||
|
login_token = Accounts.generate_user_login_token(user)
|
||||||
|
{:noreply, socket |> redirect(to: ~p"/users/log_in/#{login_token}")}
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, socket |> put_flash(:error, inspect(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
108
lib/sso_bsn_web/live/user_registration_live.ex
Normal file
108
lib/sso_bsn_web/live/user_registration_live.ex
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
defmodule SsoBsnWeb.UserRegistrationLive do
|
||||||
|
use SsoBsnWeb, :live_view
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts
|
||||||
|
alias SsoBsn.Accounts.User
|
||||||
|
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mx-auto max-w-sm">
|
||||||
|
<.header class="text-center">
|
||||||
|
Register for an account
|
||||||
|
<:subtitle>
|
||||||
|
Already registered?
|
||||||
|
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
|
||||||
|
Sign in
|
||||||
|
</.link>
|
||||||
|
to your account now.
|
||||||
|
</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<.simple_form
|
||||||
|
for={@form}
|
||||||
|
id="registration_form"
|
||||||
|
phx-submit="save"
|
||||||
|
phx-change="validate"
|
||||||
|
phx-hook="registrationHook"
|
||||||
|
phx-trigger-action={@trigger_submit}
|
||||||
|
action={~p"/users/log_in?_action=registered"}
|
||||||
|
method="post"
|
||||||
|
>
|
||||||
|
<.error :if={@check_errors}>
|
||||||
|
Oops, something went wrong! Please check the errors below.
|
||||||
|
</.error>
|
||||||
|
|
||||||
|
<.input field={@form[:username]} type="text" label="Username" required />
|
||||||
|
|
||||||
|
<:actions>
|
||||||
|
<.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
|
||||||
|
</:actions>
|
||||||
|
</.simple_form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
changeset = Accounts.change_user_registration(%User{})
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(trigger_submit: false, check_errors: false)
|
||||||
|
|> assign_form(changeset)
|
||||||
|
|
||||||
|
{:ok, socket, temporary_assigns: [form: nil]}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"user" => user_params}, socket) do
|
||||||
|
case Accounts.register_user(user_params) do
|
||||||
|
{:ok, user} ->
|
||||||
|
changeset = Accounts.change_user_registration(user)
|
||||||
|
{challenge, challenge_client} = Accounts.registration_challenge(user)
|
||||||
|
|
||||||
|
{
|
||||||
|
:noreply,
|
||||||
|
socket
|
||||||
|
# |> assign(trigger_submit: true)
|
||||||
|
|> assign_form(changeset)
|
||||||
|
|> assign(user: user)
|
||||||
|
|> assign(challenge: challenge)
|
||||||
|
|> push_event("registration-challenge", challenge_client)
|
||||||
|
}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||||
|
changeset = Accounts.change_user_registration(%User{}, user_params)
|
||||||
|
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("registration-complete", params, socket) do
|
||||||
|
%{user: user, challenge: challenge} = socket.assigns
|
||||||
|
|
||||||
|
case Accounts.register_key(user, challenge, params) do
|
||||||
|
{:ok, _key} ->
|
||||||
|
login_token = Accounts.generate_user_login_token(user)
|
||||||
|
{:noreply, socket |> redirect(to: ~p"/users/log_in/#{login_token}")}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
dbg(error)
|
||||||
|
{:noreply, socket |> put_flash(:error, "An error occured")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("error", payload, socket),
|
||||||
|
do: {:noreply, socket |> put_flash(:error, inspect(payload))}
|
||||||
|
|
||||||
|
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||||
|
form = to_form(changeset, as: "user")
|
||||||
|
|
||||||
|
if changeset.valid? do
|
||||||
|
assign(socket, form: form, check_errors: false)
|
||||||
|
else
|
||||||
|
assign(socket, form: form)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
69
lib/sso_bsn_web/live/user_settings_live.ex
Normal file
69
lib/sso_bsn_web/live/user_settings_live.ex
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
defmodule SsoBsnWeb.UserSettingsLive do
|
||||||
|
use SsoBsnWeb, :live_view
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts
|
||||||
|
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<.header class="text-center">
|
||||||
|
Account Settings
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="space-y-12 divide-y">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<.header>Keys</.header>
|
||||||
|
<ol>
|
||||||
|
<li :for={key <- @current_user.keys}><pre class="truncate"><%= key.key_id %></pre></li>
|
||||||
|
</ol>
|
||||||
|
<.button id="settings-add-key" phx-hook="registrationHook" phx-click="register">
|
||||||
|
Register additional key
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= if !@login_url do %>
|
||||||
|
<.button id="show-login-url" phx-click="show-login-url">Show login url</.button>
|
||||||
|
<% else %>
|
||||||
|
<pre><%= @login_url %></pre>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket |> assign(login_url: nil)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("register", _, socket) do
|
||||||
|
{challenge, challenge_client} = Accounts.registration_challenge(socket.assigns.current_user)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(challenge: challenge)
|
||||||
|
|> push_event("registration-challenge", challenge_client)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("registration-complete", params, socket) do
|
||||||
|
%{current_user: user, challenge: challenge} = socket.assigns
|
||||||
|
|
||||||
|
case Accounts.register_key(user, challenge, params) do
|
||||||
|
{:ok, _key} ->
|
||||||
|
{:noreply,
|
||||||
|
socket |> assign(current_user: Accounts.reload_user(socket.assigns.current_user))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
dbg(error)
|
||||||
|
{:noreply, socket |> put_flash(:error, "An error occured")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("show-login-url", %{}, socket) do
|
||||||
|
token = Accounts.generate_user_login_token(socket.assigns.current_user)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket |> assign(login_url: url(socket, ~p"/users/log_in/#{token}"))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("error", payload, socket),
|
||||||
|
do: {:noreply, socket |> put_flash(:error, inspect(payload))}
|
||||||
|
end
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule SsoBsnWeb.Router do
|
defmodule SsoBsnWeb.Router do
|
||||||
use SsoBsnWeb, :router
|
use SsoBsnWeb, :router
|
||||||
|
|
||||||
|
import SsoBsnWeb.UserAuth
|
||||||
|
|
||||||
pipeline :browser do
|
pipeline :browser do
|
||||||
plug :accepts, ["html"]
|
plug :accepts, ["html"]
|
||||||
plug :fetch_session
|
plug :fetch_session
|
||||||
@@ -8,6 +10,7 @@ defmodule SsoBsnWeb.Router do
|
|||||||
plug :put_root_layout, html: {SsoBsnWeb.Layouts, :root}
|
plug :put_root_layout, html: {SsoBsnWeb.Layouts, :root}
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
|
plug :fetch_current_user
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
@@ -25,7 +28,7 @@ defmodule SsoBsnWeb.Router do
|
|||||||
# pipe_through :api
|
# pipe_through :api
|
||||||
# end
|
# end
|
||||||
|
|
||||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
# Enable LiveDashboard in development
|
||||||
if Application.compile_env(:sso_bsn, :dev_routes) do
|
if Application.compile_env(:sso_bsn, :dev_routes) do
|
||||||
# If you want to use the LiveDashboard in production, you should put
|
# If you want to use the LiveDashboard in production, you should put
|
||||||
# it behind authentication and allow only admins to access it.
|
# it behind authentication and allow only admins to access it.
|
||||||
@@ -38,7 +41,36 @@ defmodule SsoBsnWeb.Router do
|
|||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
live_dashboard "/dashboard", metrics: SsoBsnWeb.Telemetry
|
live_dashboard "/dashboard", metrics: SsoBsnWeb.Telemetry
|
||||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
## Authentication routes
|
||||||
|
|
||||||
|
scope "/", SsoBsnWeb do
|
||||||
|
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||||
|
|
||||||
|
live_session :redirect_if_user_is_authenticated,
|
||||||
|
on_mount: [{SsoBsnWeb.UserAuth, :redirect_if_user_is_authenticated}] do
|
||||||
|
live "/users/register", UserRegistrationLive, :new
|
||||||
|
live "/users/log_in", UserLoginLive, :new
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/users/log_in/:token", UserSessionController, :login
|
||||||
|
post "/users/log_in", UserSessionController, :create
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", SsoBsnWeb do
|
||||||
|
pipe_through [:browser, :require_authenticated_user]
|
||||||
|
|
||||||
|
live_session :require_authenticated_user,
|
||||||
|
on_mount: [{SsoBsnWeb.UserAuth, :ensure_authenticated}] do
|
||||||
|
live "/users/settings", UserSettingsLive, :edit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", SsoBsnWeb do
|
||||||
|
pipe_through [:browser]
|
||||||
|
|
||||||
|
delete "/users/log_out", UserSessionController, :delete
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
227
lib/sso_bsn_web/user_auth.ex
Normal file
227
lib/sso_bsn_web/user_auth.ex
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
defmodule SsoBsnWeb.UserAuth do
|
||||||
|
use SsoBsnWeb, :verified_routes
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
alias SsoBsn.Accounts
|
||||||
|
|
||||||
|
# Make the remember me cookie valid for 60 days.
|
||||||
|
# If you want bump or reduce this value, also change
|
||||||
|
# the token expiry itself in UserToken.
|
||||||
|
@max_age 60 * 60 * 24 * 60
|
||||||
|
@remember_me_cookie "_sso_bsn_web_user_remember_me"
|
||||||
|
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs the user in.
|
||||||
|
|
||||||
|
It renews the session ID and clears the whole session
|
||||||
|
to avoid fixation attacks. See the renew_session
|
||||||
|
function to customize this behaviour.
|
||||||
|
|
||||||
|
It also sets a `:live_socket_id` key in the session,
|
||||||
|
so LiveView sessions are identified and automatically
|
||||||
|
disconnected on log out. The line can be safely removed
|
||||||
|
if you are not using LiveView.
|
||||||
|
"""
|
||||||
|
def log_in_user(conn, user, params \\ %{}) do
|
||||||
|
token = Accounts.generate_user_session_token(user)
|
||||||
|
user_return_to = get_session(conn, :user_return_to)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> renew_session()
|
||||||
|
|> put_token_in_session(token)
|
||||||
|
|> maybe_write_remember_me_cookie(token, params)
|
||||||
|
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
|
||||||
|
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_write_remember_me_cookie(conn, _token, _params) do
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
|
||||||
|
# This function renews the session ID and erases the whole
|
||||||
|
# session to avoid fixation attacks. If there is any data
|
||||||
|
# in the session you may want to preserve after log in/log out,
|
||||||
|
# you must explicitly fetch the session data before clearing
|
||||||
|
# and then immediately set it after clearing, for example:
|
||||||
|
#
|
||||||
|
# defp renew_session(conn) do
|
||||||
|
# preferred_locale = get_session(conn, :preferred_locale)
|
||||||
|
#
|
||||||
|
# conn
|
||||||
|
# |> configure_session(renew: true)
|
||||||
|
# |> clear_session()
|
||||||
|
# |> put_session(:preferred_locale, preferred_locale)
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
defp renew_session(conn) do
|
||||||
|
conn
|
||||||
|
|> configure_session(renew: true)
|
||||||
|
|> clear_session()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Logs the user out.
|
||||||
|
|
||||||
|
It clears all session data for safety. See renew_session.
|
||||||
|
"""
|
||||||
|
def log_out_user(conn) do
|
||||||
|
user_token = get_session(conn, :user_token)
|
||||||
|
user_token && Accounts.delete_user_session_token(user_token)
|
||||||
|
|
||||||
|
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||||
|
SsoBsnWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> renew_session()
|
||||||
|
|> delete_resp_cookie(@remember_me_cookie)
|
||||||
|
|> redirect(to: ~p"/")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Authenticates the user by looking into the session
|
||||||
|
and remember me token.
|
||||||
|
"""
|
||||||
|
def fetch_current_user(conn, _opts) do
|
||||||
|
{user_token, conn} = ensure_user_token(conn)
|
||||||
|
user = user_token && Accounts.get_user_by_session_token(user_token)
|
||||||
|
assign(conn, :current_user, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_user_token(conn) do
|
||||||
|
if token = get_session(conn, :user_token) do
|
||||||
|
{token, conn}
|
||||||
|
else
|
||||||
|
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||||
|
|
||||||
|
if token = conn.cookies[@remember_me_cookie] do
|
||||||
|
{token, put_token_in_session(conn, token)}
|
||||||
|
else
|
||||||
|
{nil, conn}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Handles mounting and authenticating the current_user in LiveViews.
|
||||||
|
|
||||||
|
## `on_mount` arguments
|
||||||
|
|
||||||
|
* `:mount_current_user` - Assigns current_user
|
||||||
|
to socket assigns based on user_token, or nil if
|
||||||
|
there's no user_token or no matching user.
|
||||||
|
|
||||||
|
* `:ensure_authenticated` - Authenticates the user from the session,
|
||||||
|
and assigns the current_user to socket assigns based
|
||||||
|
on user_token.
|
||||||
|
Redirects to login page if there's no logged user.
|
||||||
|
|
||||||
|
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
|
||||||
|
Redirects to signed_in_path if there's a logged user.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
|
||||||
|
the current_user:
|
||||||
|
|
||||||
|
defmodule SsoBsnWeb.PageLive do
|
||||||
|
use SsoBsnWeb, :live_view
|
||||||
|
|
||||||
|
on_mount {SsoBsnWeb.UserAuth, :mount_current_user}
|
||||||
|
...
|
||||||
|
end
|
||||||
|
|
||||||
|
Or use the `live_session` of your router to invoke the on_mount callback:
|
||||||
|
|
||||||
|
live_session :authenticated, on_mount: [{SsoBsnWeb.UserAuth, :ensure_authenticated}] do
|
||||||
|
live "/profile", ProfileLive, :index
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
def on_mount(:mount_current_user, _params, session, socket) do
|
||||||
|
{:cont, mount_current_user(socket, session)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:ensure_authenticated, _params, session, socket) do
|
||||||
|
socket = mount_current_user(socket, session)
|
||||||
|
|
||||||
|
if socket.assigns.current_user do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||||
|
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
|
||||||
|
socket = mount_current_user(socket, session)
|
||||||
|
|
||||||
|
if socket.assigns.current_user do
|
||||||
|
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
|
||||||
|
else
|
||||||
|
{:cont, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mount_current_user(socket, session) do
|
||||||
|
Phoenix.Component.assign_new(socket, :current_user, fn ->
|
||||||
|
if user_token = session["user_token"] do
|
||||||
|
Accounts.get_user_by_session_token(user_token)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Used for routes that require the user to not be authenticated.
|
||||||
|
"""
|
||||||
|
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||||
|
if conn.assigns[:current_user] do
|
||||||
|
conn
|
||||||
|
|> redirect(to: signed_in_path(conn))
|
||||||
|
|> halt()
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Used for routes that require the user to be authenticated.
|
||||||
|
|
||||||
|
If you want to enforce the user email is confirmed before
|
||||||
|
they use the application at all, here would be a good place.
|
||||||
|
"""
|
||||||
|
def require_authenticated_user(conn, _opts) do
|
||||||
|
if conn.assigns[:current_user] do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "You must log in to access this page.")
|
||||||
|
|> maybe_store_return_to()
|
||||||
|
|> redirect(to: ~p"/users/log_in")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_token_in_session(conn, token) do
|
||||||
|
conn
|
||||||
|
|> put_session(:user_token, token)
|
||||||
|
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||||
|
put_session(conn, :user_return_to, current_path(conn))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_store_return_to(conn), do: conn
|
||||||
|
|
||||||
|
defp signed_in_path(_conn), do: ~p"/users/settings"
|
||||||
|
end
|
||||||
10
mix.exs
10
mix.exs
@@ -23,8 +23,6 @@ defmodule SsoBsn.MixProject do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Specifies which paths to compile per environment.
|
|
||||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
|
||||||
defp elixirc_paths(_), do: ["lib"]
|
defp elixirc_paths(_), do: ["lib"]
|
||||||
|
|
||||||
# Specifies your project dependencies.
|
# Specifies your project dependencies.
|
||||||
@@ -32,6 +30,7 @@ defmodule SsoBsn.MixProject do
|
|||||||
# Type `mix help deps` for examples and options.
|
# Type `mix help deps` for examples and options.
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
|
{:bcrypt_elixir, "~> 3.0"},
|
||||||
{:phoenix, "~> 1.7.9"},
|
{:phoenix, "~> 1.7.9"},
|
||||||
{:phoenix_ecto, "~> 4.4"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
{:ecto_sql, "~> 3.10"},
|
{:ecto_sql, "~> 3.10"},
|
||||||
@@ -39,18 +38,16 @@ defmodule SsoBsn.MixProject do
|
|||||||
{:phoenix_html, "~> 3.3"},
|
{:phoenix_html, "~> 3.3"},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||||
{:phoenix_live_view, "~> 0.20.1"},
|
{:phoenix_live_view, "~> 0.20.1"},
|
||||||
{:floki, ">= 0.30.0", only: :test},
|
|
||||||
{:phoenix_live_dashboard, "~> 0.8.2"},
|
{:phoenix_live_dashboard, "~> 0.8.2"},
|
||||||
{:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
|
{:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
|
||||||
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
|
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
|
||||||
{:swoosh, "~> 1.3"},
|
|
||||||
{:finch, "~> 0.13"},
|
|
||||||
{:telemetry_metrics, "~> 0.6"},
|
{:telemetry_metrics, "~> 0.6"},
|
||||||
{:telemetry_poller, "~> 1.0"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:gettext, "~> 0.20"},
|
{:gettext, "~> 0.20"},
|
||||||
{:jason, "~> 1.2"},
|
{:jason, "~> 1.2"},
|
||||||
{:dns_cluster, "~> 0.1.1"},
|
{:dns_cluster, "~> 0.1.1"},
|
||||||
{:plug_cowboy, "~> 2.5"}
|
{:plug_cowboy, "~> 2.5"},
|
||||||
|
{:wax_, "~> 0.6.0"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -65,7 +62,6 @@ defmodule SsoBsn.MixProject do
|
|||||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
|
||||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||||
"assets.build": ["tailwind default", "esbuild default"],
|
"assets.build": ["tailwind default", "esbuild default"],
|
||||||
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
|
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
|
||||||
|
|||||||
6
mix.lock
6
mix.lock
@@ -1,6 +1,10 @@
|
|||||||
%{
|
%{
|
||||||
|
"asn1_compiler": {:hex, :asn1_compiler, "0.1.1", "64a4e52b59d1f225878445ace2c75cd2245b13a5a81182304fd9dc5acfc8994e", [:mix], [], "hexpm", "c250d24c22f1a3f305d88864400f9ac2df55c6886e1e3a030e2946efeb94695e"},
|
||||||
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"},
|
||||||
"castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"},
|
"castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"},
|
||||||
|
"cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.8", "933a5f4da3b19ee56539a076076ce4d7716d64efc8db46fd066996a7e46e2bfd", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "176bdf4366956e456bf761b54ad70bc4103d0269ca9558fd7cee93d1b3f116db"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.8", "933a5f4da3b19ee56539a076076ce4d7716d64efc8db46fd066996a7e46e2bfd", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "176bdf4366956e456bf761b54ad70bc4103d0269ca9558fd7cee93d1b3f116db"},
|
||||||
|
"comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"},
|
||||||
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
|
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
|
||||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||||
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
|
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
|
||||||
@@ -41,6 +45,8 @@
|
|||||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
||||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
|
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
|
||||||
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
|
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
|
||||||
|
"wax_": {:hex, :wax_, "0.6.3", "c0cf63819e21f7b6f4e9e3702d52b6837d58c02491b876fe54453729c2cc1a79", [:mix], [{:asn1_compiler, "~> 0.1.0", [hex: :asn1_compiler, repo: "hexpm", optional: false]}, {:cbor, "~> 1.0", [hex: :cbor, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:x509, "~> 0.8", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "c25a5ad88edc741c10d030a46fb6f0525fe253f258da0e6ce72ec2b3327c5b1c"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"},
|
||||||
|
"x509": {:hex, :x509, "0.8.8", "aaf5e58b19a36a8e2c5c5cff0ad30f64eef5d9225f0fd98fb07912ee23f7aba3", [:mix], [], "hexpm", "ccc3bff61406e5bb6a63f06d549f3dba3a1bbb456d84517efaaa210d8a33750f"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
defmodule SsoBsn.Repo.Migrations.CreateUsersAuthTables do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:users) do
|
||||||
|
add :username, :string, null: false, collate: :nocase
|
||||||
|
add :confirmed_at, :naive_datetime
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:users, [:username])
|
||||||
|
|
||||||
|
create table(:users_keys) do
|
||||||
|
add :key_id, :string, null: false
|
||||||
|
add :cose_key, :binary, null: false
|
||||||
|
|
||||||
|
add :user_id, references(:users, on_delete: :delete_all), null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:users_keys, [:key_id])
|
||||||
|
|
||||||
|
create table(:users_tokens) do
|
||||||
|
add :user_id, references(:users, on_delete: :delete_all), null: false
|
||||||
|
add :token, :binary, null: false, size: 32
|
||||||
|
add :context, :string, null: false
|
||||||
|
add :sent_to, :string
|
||||||
|
timestamps(updated_at: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:users_tokens, [:user_id])
|
||||||
|
create unique_index(:users_tokens, [:context, :token])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
defmodule SsoBsnWeb.ErrorHTMLTest do
|
|
||||||
use SsoBsnWeb.ConnCase, async: true
|
|
||||||
|
|
||||||
# Bring render_to_string/4 for testing custom views
|
|
||||||
import Phoenix.Template
|
|
||||||
|
|
||||||
test "renders 404.html" do
|
|
||||||
assert render_to_string(SsoBsnWeb.ErrorHTML, "404", "html", []) == "Not Found"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "renders 500.html" do
|
|
||||||
assert render_to_string(SsoBsnWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
defmodule SsoBsnWeb.ErrorJSONTest do
|
|
||||||
use SsoBsnWeb.ConnCase, async: true
|
|
||||||
|
|
||||||
test "renders 404" do
|
|
||||||
assert SsoBsnWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "renders 500" do
|
|
||||||
assert SsoBsnWeb.ErrorJSON.render("500.json", %{}) ==
|
|
||||||
%{errors: %{detail: "Internal Server Error"}}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
defmodule SsoBsnWeb.PageControllerTest do
|
|
||||||
use SsoBsnWeb.ConnCase
|
|
||||||
|
|
||||||
test "GET /", %{conn: conn} do
|
|
||||||
conn = get(conn, ~p"/")
|
|
||||||
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
defmodule SsoBsnWeb.ConnCase do
|
|
||||||
@moduledoc """
|
|
||||||
This module defines the test case to be used by
|
|
||||||
tests that require setting up a connection.
|
|
||||||
|
|
||||||
Such tests rely on `Phoenix.ConnTest` and also
|
|
||||||
import other functionality to make it easier
|
|
||||||
to build common data structures and query the data layer.
|
|
||||||
|
|
||||||
Finally, if the test case interacts with the database,
|
|
||||||
we enable the SQL sandbox, so changes done to the database
|
|
||||||
are reverted at the end of every test. If you are using
|
|
||||||
PostgreSQL, you can even run database tests asynchronously
|
|
||||||
by setting `use SsoBsnWeb.ConnCase, async: true`, although
|
|
||||||
this option is not recommended for other databases.
|
|
||||||
"""
|
|
||||||
|
|
||||||
use ExUnit.CaseTemplate
|
|
||||||
|
|
||||||
using do
|
|
||||||
quote do
|
|
||||||
# The default endpoint for testing
|
|
||||||
@endpoint SsoBsnWeb.Endpoint
|
|
||||||
|
|
||||||
use SsoBsnWeb, :verified_routes
|
|
||||||
|
|
||||||
# Import conveniences for testing with connections
|
|
||||||
import Plug.Conn
|
|
||||||
import Phoenix.ConnTest
|
|
||||||
import SsoBsnWeb.ConnCase
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
setup tags do
|
|
||||||
SsoBsn.DataCase.setup_sandbox(tags)
|
|
||||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
defmodule SsoBsn.DataCase do
|
|
||||||
@moduledoc """
|
|
||||||
This module defines the setup for tests requiring
|
|
||||||
access to the application's data layer.
|
|
||||||
|
|
||||||
You may define functions here to be used as helpers in
|
|
||||||
your tests.
|
|
||||||
|
|
||||||
Finally, if the test case interacts with the database,
|
|
||||||
we enable the SQL sandbox, so changes done to the database
|
|
||||||
are reverted at the end of every test. If you are using
|
|
||||||
PostgreSQL, you can even run database tests asynchronously
|
|
||||||
by setting `use SsoBsn.DataCase, async: true`, although
|
|
||||||
this option is not recommended for other databases.
|
|
||||||
"""
|
|
||||||
|
|
||||||
use ExUnit.CaseTemplate
|
|
||||||
|
|
||||||
using do
|
|
||||||
quote do
|
|
||||||
alias SsoBsn.Repo
|
|
||||||
|
|
||||||
import Ecto
|
|
||||||
import Ecto.Changeset
|
|
||||||
import Ecto.Query
|
|
||||||
import SsoBsn.DataCase
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
setup tags do
|
|
||||||
SsoBsn.DataCase.setup_sandbox(tags)
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Sets up the sandbox based on the test tags.
|
|
||||||
"""
|
|
||||||
def setup_sandbox(tags) do
|
|
||||||
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(SsoBsn.Repo, shared: not tags[:async])
|
|
||||||
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
A helper that transforms changeset errors into a map of messages.
|
|
||||||
|
|
||||||
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
|
||||||
assert "password is too short" in errors_on(changeset).password
|
|
||||||
assert %{password: ["password is too short"]} = errors_on(changeset)
|
|
||||||
|
|
||||||
"""
|
|
||||||
def errors_on(changeset) do
|
|
||||||
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
|
||||||
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
|
||||||
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ExUnit.start()
|
|
||||||
Ecto.Adapters.SQL.Sandbox.mode(SsoBsn.Repo, :manual)
|
|
||||||
Reference in New Issue
Block a user