Implementation of a basic auth logic via API #2

Open
optclblast wants to merge 11 commits from base-auth into master
29 changed files with 496 additions and 97 deletions

View File

@ -0,0 +1,32 @@
name: Base CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
permissions:
contents: read
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Elixir
uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0
with:
elixir-version: '1.14.0' # [Required] Define the Elixir version
otp-version: '25.0' # [Required] Define the Erlang/OTP version
- name: Restore dependencies cache
uses: actions/cache@v3
with:
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Install dependencies
run: mix deps.get
- name: Run tests
run: mix test

32
.github/workflows/elixir.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Base CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
permissions:
contents: read
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Elixir
uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0
with:
elixir-version: '1.14.0' # [Required] Define the Elixir version
otp-version: '25.0' # [Required] Define the Erlang/OTP version
- name: Restore dependencies cache
uses: actions/cache@v3
with:
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Install dependencies
run: mix deps.get
- name: Run tests
run: mix test

11
Makefile Normal file
View File

@ -0,0 +1,11 @@
up:
sudo docker compose up -d
make migrate-up
mix phx.server
migrate-up:
mix ecto.migrate
migrate-down:
mix ecto.rollback

View File

@ -14,6 +14,13 @@ Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
### Dev DB creds
From *./config/dev.exs*
Host: localhost:5432
Database: draincloud_core_dev
Username: postgres
Password: postgres
### Some phoenix related links
* Official website: https://www.phoenixframework.org/

View File

@ -9,24 +9,24 @@ services:
ports:
- 5432:5432
minio:
image: quay.io/minio/minio:RELEASE.2024-08-29T01-40-52Z
command: server --console-address ":9001" http://minio/data{1...2}
hostname: minio
volumes:
- data-1:/data1
- data-2:/data2
expose:
- "9000"
- "9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
# minio:
# image: quay.io/minio/minio:RELEASE.2024-08-29T01-40-52Z
# command: server --console-address ":9001" http://minio/data{1...2}
# hostname: minio
# volumes:
# - data-1:/data1
# - data-2:/data2
# expose:
# - "9000"
# - "9001"
# environment:
# MINIO_ROOT_USER: minioadmin
# MINIO_ROOT_PASSWORD: minioadmin
# healthcheck:
# test: ["CMD", "mc", "ready", "local"]
# interval: 5s
# timeout: 5s
# retries: 5
volumes:
draincore-data:

View File

@ -1,6 +1,10 @@
defmodule DraincloudCore.Auth do
def is_logon(_token) do
# TODO here
true
false
end
def auth_cookies(conn = %Plug.Conn{}, user = %DrainCloudCore.Auth.Users{}) do
end
end

View File

@ -0,0 +1,21 @@
defmodule DrainCloudCore.Auth.Session do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
schema "sessions" do
field :token, :string
field :user_id, :id
field :user_agent, :string
field :created_at, :utc_datetime
field :expires_at, :utc_datetime
end
def changeset(session, params \\ %{}) do
session
|> cast(params, [:id, :token, :user_id, :user_agent, :created_at, :expires_at])
|> validate_required([:id, :token, :user_id, :user_agent, :created_at, :expires_at])
end
end

View File

@ -0,0 +1,45 @@
defmodule DrainCloudCore.Auth.SessionsStore do
@behaviour Plug.Session.Store
@token_len 64
alias DrainCloudCore.Auth.SessionsRepo
alias DrainCloudCore.Repo, as: Repo
def init(_opts), do: :ok
def put(conn, sid, any, opts) do
end
def get(conn, cookie, opts) do
end
def delete(conn, sid, opts) do
end
def new_session(conn, user) do
%DrainCloudCore.Auth.Session {
user_id: user.id,
token: new_token(@token_len),
user_agent: user_agent(conn),
created_at: DateTime.utc_now(),
expires_at: DateTime.add(DateTime.utc_now(), 7*24, :hour)
}
end
defp user_agent(conn) do
Enum.find_value(conn.req_headers, "", fn x ->
case x do
{"user-agent", agent} -> agent
_ -> nil
end
end)
end
defp new_token(length) do
:crypto.strong_rand_bytes(length) |> Base.url_encode64 |> binary_part(0, length)
end
end

View File

@ -0,0 +1,29 @@
defmodule DrainCloudCore.Auth.Users do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias DrainCloudCore.Repo, as: Repo
schema "users" do
field :login, :string
field :password, :string, redact: true
field :created_at, :utc_datetime
field :updated_at, :utc_datetime
field :deleted_at, :utc_datetime
end
def changeset(user, params \\ %{}) do
user
|> cast(params, [:id, :login, :password, :updated_at, :deleted_at])
|> validate_required([:id])
end
def add_user(user) do
Repo.insert(user)
end
def password_by_login(login) do
Repo.all(from u in "users", where: u.login == ^login, select: u.password, limit: 1)
end
end

View File

@ -10,6 +10,7 @@ defmodule DrainCloudCore.Rtc.Application do
type: :supervisor
}
end
def start_link(_) do
children = [
DrainCloudCore.Rtc
@ -27,27 +28,32 @@ defmodule DrainCloudCore.Rtc do
use GenServer
# Real-time config table schemas
@config_web [attributes: [
:host,
:enable_https,
:port,
]]
@config_web [
attributes: [
:host,
:enable_https,
:port
]
]
@config_pg [attributes: [
:host,
:port,
:ssl,
:user,
:password,
:db,
]]
@config_pg [
attributes: [
:host,
:port,
:ssl,
:user,
:password,
:db
]
]
@config_s3 [attributes: [
:host,
:port,
# user / secret / secrets etc...
]]
@config_s3 [
attributes: [
:host,
:port
# user / secret / secrets etc...
]
]
# def child_spec(opts) do
# %{
@ -81,6 +87,7 @@ defmodule DrainCloudCore.Rtc do
def fetch_config() do
# Mnesia.read()
end
def init() do
Log.info("[#{__MODULE__}] start initializin RTC subsystems")

View File

@ -8,8 +8,13 @@
<%= assigns[:page_title] || "DrainCloudCore" %>
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous"></script>
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
<script
src="https://unpkg.com/htmx.org@2.0.2"
integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ"
crossorigin="anonymous"
>
</script>
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body class="bg-white">

View File

@ -1,12 +1,45 @@
defmodule DrainCloudCoreWeb.AuthController do
use DrainCloudCoreWeb, :controller
alias DrainCloudCoreWeb.Request, as: Request
def logon(conn, _params) do
if Request.hs_token?(conn) do
# TODO validate token here
import Plug.Conn
alias DrainCloudCoreWeb.AuthController.RegisterRequest, as: RegisterRequest
alias DrainCloudCoreWeb.AuthController.LoginRequest, as: LoginRequest
alias :logger, as: Log
alias DrainCloudCore.Auth.Users , as: Repo
def register(conn, _params) do
Log.debug(
Enum.find_value(conn.req_headers, "", fn x ->
case x do
{"user-agent", agent} -> agent
_ -> nil
end
end)
|> inspect
)
try do
RegisterRequest.from_request(conn)
|> RegisterRequest.to_model
|> Repo.add_user
send_resp(conn, 200, Jason.encode! %{ok: true})
rescue
e in RuntimeError ->
Log.error("failed to create new user: #{e}")
raise e
end
end
def login(conn, _params) do
try do
LoginRequest.from_request(conn)
# TODO send cookies and tokens
send_resp(conn, 200, Jason.encode! %{ok: true})
rescue
e in RuntimeError ->
Log.error("failed to insert new user: #{e}")
raise e
end
end
end

View File

@ -0,0 +1,68 @@
defmodule DraincloudCoreWeb.AuthController.LoginRequest do
@derive [Poison.Encoder, Jason.Encoder]
defstruct login: "", password: ""
alias __MODULE__, as: Request
alias DrainCloudCore.Auth.Users, as: User
alias DrainCloudCoreWeb.Errors.InvalidArgumentException, as: InvalidArgumentException
def from_request(conn = %Plug.Conn{}) do
validate_and_build(conn.params)
end
def to_model(req = %Request{}) do
%User{
login: req.login,
password: req.password
}
end
defp validate_and_build(req) do
validate(req)
|> build
end
defp validate(req) do
validate_login(req)
|> validate_password
end
defp validate_login(req) do
if String.length(Map.get(req, "login")) >= 3 do
req
else
raise InvalidArgumentException, %{
message: "login must be 3 symbols at minimum",
args: ["login"]
}
end
end
defp validate_password(req) do
if String.length(Map.get(req, "password")) >= 8 do
req
else
raise InvalidArgumentException, %{
message: "password must be 8 symbols at minimum",
args: ["password"]
}
end
end
defp build(req) do
%Request{
login: Map.get(req, "login"),
password: Map.get(req, "password") # TODO hash password
}
end
defimpl String.Chars, for: __MODULE__ do
def to_string(req) do
case Jason.encode(req) do
{:ok, str} -> str
{:error, msg} -> raise "failed to encode login request to string: #{msg}"
end
end
end
end

View File

@ -0,0 +1,71 @@
defmodule DrainCloudCoreWeb.AuthController.RegisterRequest do
@derive [Poison.Encoder, Jason.Encoder]
defstruct login: "", password: ""
alias __MODULE__, as: Request
alias DrainCloudCore.Auth.Users, as: User
alias DrainCloudCoreWeb.Errors.InvalidArgumentException, as: InvalidArgumentException
def from_request(conn = %Plug.Conn{}) do
# TODO remove Kernel.inspect calls
:logger.debug("[from_request] incoming request: #{Kernel.inspect(conn.params)}")
validate_and_build(conn.params)
end
def to_model(req = %Request{}) do
%User{
login: req.login,
password: req.password
}
end
defp validate_and_build(req) do
validate(req)
|> build
end
defp validate(req) do
validate_login(req)
|> validate_password
end
defp validate_login(req) do
if String.length(Map.get(req, "login")) >= 3 do
req
else
raise InvalidArgumentException, %{
message: "login must be 3 symbols at minimum",
args: ["login"]
}
end
end
defp validate_password(req) do
if String.length(Map.get(req, "password")) >= 4 do
req
else
raise InvalidArgumentException, %{
message: "password must be 4 symbols at minimum",
args: ["password"]
}
end
end
defp build(req) do
%Request{
login: Map.get(req, "login"),
password: Map.get(req, "password") # TODO hash password
}
end
defimpl String.Chars, for: __MODULE__ do
def to_string(req) do
case Jason.encode(req) do
{:ok, str} -> str
{:error, msg} -> raise "failed to encode register request to string: #{msg}"
end
end
end
end

View File

@ -0,0 +1,28 @@
defmodule DrainCloudCoreWeb.Errors.InvalidArgumentException do
@derive [Jason.Encoder]
alias __MODULE__, as: InvalidArgumentException
defexception [:message, :args]
@impl true
def exception(%{message: message, args: args}) do
%InvalidArgumentException{message: message, args: args}
end
@impl true
def exception(term) do
case term do
[] -> %InvalidArgumentException{}
_ -> %InvalidArgumentException{message: "Error: " <> term}
end
end
defimpl String.Chars, for: __MODULE__ do
def to_string(err) do
case Jason.encode(err) do
{:ok, str} -> str
{:error, msg} -> raise "failed to encode register request to string: #{msg}"
end
end
end
end

View File

@ -7,5 +7,4 @@ defmodule DrainCloudCoreWeb.MainController do
|> put_root_layout(false)
|> json(%{data: %{name: "Some Name"}})
end
end

View File

@ -15,10 +15,6 @@ defmodule DrainCloudCoreWeb.Endpoint do
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug Plug.Static,
at: "/",
from: :draincloud_core,

View File

@ -0,0 +1,10 @@
defmodule DrainCloudCoreWeb.ErrorHandler do
alias DrainCloudCoreWeb.Errors.InvalidArgumentException, as: InvalidArgumentException
def handle_reason(reason = %InvalidArgumentException{}), do: Jason.encode!(reason)
def handle_reason(reason) do
:logger.error(inspect(reason))
Jason.encode!(%{error: "Oops! Something went wrong!"})
end
end

View File

@ -1,24 +1,3 @@
defmodule DrainCloudCoreWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example:
import DrainCloudCoreWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext, otp_app: :draincloud_core
end

View File

@ -1,5 +1,9 @@
defmodule DrainCloudCoreWeb.Router do
use DrainCloudCoreWeb, :router
use Plug.ErrorHandler
import Plug.Conn
alias DrainCloudCoreWeb.ErrorHandler, as: ErrorHandler
pipeline :browser do
plug :accepts, ["html"]
@ -14,24 +18,19 @@ defmodule DrainCloudCoreWeb.Router do
plug :accepts, ["json"]
end
scope "/", DrainCloudCoreWeb do
scope "/api", DrainCloudCoreWeb do
pipe_through :api
get "/api", MainController, :test
post "/register", AuthController, :register
end
# Other scopes may use custom stacks.
# scope "/api", DrainCloudCoreWeb do
# pipe_through :api
# end
@impl Plug.ErrorHandler
def handle_errors(conn, %{kind: _kind, reason: reason, stack: _stack}) do
send_resp(conn, 500, ErrorHandler.handle_reason(reason))
end
# Enable LiveDashboard in development
if Application.compile_env(:draincloud_core, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do

View File

@ -44,6 +44,7 @@ defmodule DrainCloudCore.MixProject do
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
{:poison, "~> 6.0"},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.1.1",

View File

@ -27,6 +27,7 @@
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"poison": {:hex, :poison, "6.0.0", "9bbe86722355e36ffb62c51a552719534257ba53f3271dacd20fbbd6621a583a", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "bb9064632b94775a3964642d6a78281c07b7be1319e0016e1643790704e739a2"},
"postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"},

View File

@ -1,7 +0,0 @@
defmodule DrainCloudCore.Repo.Migrations.Users do
use Ecto.Migration
def change do
# TODO users table migration
end
end

View File

@ -0,0 +1,15 @@
defmodule DrainCloudCore.Repo.Migrations.AddUsers do
use Ecto.Migration
def change do
create table(:users) do
add :login, :string, size: 120
add :password, :string
add :created_at, :utc_datetime, null: false, default: fragment("CURRENT_TIMESTAMP")
add :updated_at, :utc_datetime, null: false, default: fragment("CURRENT_TIMESTAMP")
add :deleted_at, :utc_datetime, null: true, default: nil
end
create unique_index(:users, :login)
end
end

View File

@ -0,0 +1,15 @@
defmodule DrainCloudCore.Repo.Migrations.Sessions do
use Ecto.Migration
def change do
create table(:sessions) do
add :token, :string
add :user_id, references("users", on_delete: :nothing)
add :user_agent, :string
add :created_at, :utc_datetime
add :expires_at, :utc_datetime
end
create unique_index(:sessions, :token)
end
end

View File

@ -1,5 +0,0 @@
create table users (
id bigserial,
login varchar(50),
password varchar(256)
);

View File

@ -9,6 +9,7 @@ defmodule DrainCloudCoreWeb.ErrorHTMLTest do
end
test "renders 500.html" do
assert render_to_string(DrainCloudCoreWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
assert render_to_string(DrainCloudCoreWeb.ErrorHTML, "500", "html", []) ==
"Internal Server Error"
end
end

View File

@ -2,7 +2,9 @@ defmodule DrainCloudCoreWeb.ErrorJSONTest do
use DrainCloudCoreWeb.ConnCase, async: true
test "renders 404" do
assert DrainCloudCoreWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
assert DrainCloudCoreWeb.ErrorJSON.render("404.json", %{}) == %{
errors: %{detail: "Not Found"}
}
end
test "renders 500" do