Skip to content

Instantly share code, notes, and snippets.

@CharlesOkwuagwu
Created December 12, 2017 14:29
Show Gist options
  • Save CharlesOkwuagwu/e2620a333959855b4afa16de1ed77e0b to your computer and use it in GitHub Desktop.
Save CharlesOkwuagwu/e2620a333959855b4afa16de1ed77e0b to your computer and use it in GitHub Desktop.
Raxx Web server + SSE + CORS + JWT -auth
defmodule RMAS.WebServer do
## setup
@moduledoc false
require Logger
use Raxx.Server
use Raxx.Static, "../dist"
## variables
{:ok, contents} = File.read("./dist/index.html")
@contents contents
@format [pretty: true, limit: :infinity, width: :infinity]
@jwt_key "some-really-secret-guid"
## handle requests
@doc "
- handle OPTIONS for CORS pre-flight
- handles /sse
- handle /api
- fallback for regular content"
def handle_head(r, s) do
Logger.info("[#{inspect self()}] #{inspect r, @format}")
try do
case r do
%{method: :TRACE} -> trace_request(r, s)
%{path: ["api" | _], method: :OPTIONS} -> preflight_request(r, s)
# %{path: ["api" | _]} -> api_request(r, s)
# %{path: ["sse" | _]} -> sse_request(r, s)
# _ -> general_request(r, s)
_ -> {[], s}
end
rescue
ex ->
Logger.error("[#{inspect self()}] #{inspect(ex)}\n#{Exception.format_stacktrace(System.stacktrace)}")
{[response(500) |> set_body(false)], s}
end
end
def handle_info(r, s) do
Logger.warn "#{inspect(r, @format)}"
r
end
def handle_data(data, {r, buffer, s}) do
r = %{r | body: buffer <> data}
case r do
%{path: ["api" | _]} -> api_request(r, s)
_ -> general_request(r, s)
end
end
defp trace_request(%{body: body}, state) do
outbound = [
response(200)
|> set_body(body)
]
{outbound, state}
end
defp preflight_request(%{headers: headers}, state) do # OPTIONS -> handle pre-filght correctly
h = Enum.into(headers, %{})
m = h["access-control-request-method"]
cors = [
{"access-control-allow-credentials", "true"},
{"access-control-allow-headers", "authorization,content-type"},
{"access-control-allow-origin", h["origin"]}
]
cors =
if m in ["DELETE", "PUT", "PATCH"] do
cors ++ [{"access-control-allow-methods", m}]
else
cors
end
outbound = [
response(204)
|> set_headers(cors)
|> set_body(false)
]
Logger.info("[#{inspect self()}] #{inspect outbound, @format}")
{outbound, state}
end
def api_request(req, state) do # api -> cors, request/response + json, check token
outbound = [
proc_request(req)
]
{outbound, state}
end
defp sse_request(%{headers: headers}, state) do # sse
h = Enum.into(headers, %{})
outbound = [
response(200)
|> set_header("content-type", "text/event-stream")
|> set_header("access-control-allow-origin", h["origin"])
|> set_header("access-control-allow-credentials", "true")
|> set_body(true)
]
{outbound, state}
end
defp general_request(_req, state) do # fallback -> request/response
outbound = [
response(200)
|> set_header("content-type", "text/html; charset=utf-8")
|> set_body(@contents)
]
{outbound, state}
end
## Report Runner
defp proc_request(%{method: :POST, path: ["api", "report-runner"], body: data}) do
with {:ok, %{userid: userid, code: code, date: date}} <- Antidote.decode(data, keys: :atoms) do
RMAS.Server.run(userid, code, date, true)
cors_response(200)
else
e ->
Logger.warn("#{inspect(e, @format)}")
cors_response(400)
end
end
## Auth
defp proc_request(%{method: :POST, path: ["api", "auth", "login"], body: data}) do
with {:ok, m} <- Antidote.decode(data, keys: :atoms),
{:ok, 1, _, [u]} <- DB.Users.list(%{email: m.username}),
{:ok, true} <- verify_password(u.password, m.password) do
token = generate_token(u.user_id)
r = %{user: un_struct(u), token: token}
json = Antidote.encode!(r)
cors_response(json)
else
{:error, msg} ->
Logger.warn("#{inspect(msg, @format)}")
cors_response(:unauthorized)
_ -> cors_response(:unauthorized)
end
end
defp proc_request(%{method: :GET, path: ["api", "auth", "test"]}) do
cors_response(%{status: :OK})
end
## Other
defp proc_request(%{method: :POST, path: ["api", "clearlogs"]}), do: run(204, DB.execute("truncate table systemlog"))
defp proc_request(%{method: :GET, path: ["api", "systemlog"]}), do: run(200, DB.execute("select id, l.user_id, isnull(u.name,'SYSTEM') runner, batch, report, report_dt, status, dt_start, dt_end, DATEDIFF(MS, dt_start, dt_end) as [ms_durn] from systemlog l left join users u on u.user_id = l.user_id order by batch desc, id"))
defp proc_request(%{method: :GET, path: ["api", entity]}), do: run(200, DB.execute("select * from #{entity}"))
defp proc_request(%{method: :GET, path: ["api", entity, id]}), do: run(200, DB.select(id, entity))
## Users
defp proc_request(%{method: :GET, path: ["api", "users"]}), do: run(200, DB.Users.list())
defp proc_request(%{method: :GET, path: ["api", "users", id]}), do: run(200, DB.Users.get(id))
defp proc_request(%{method: :POST, path: ["api", "users"], body: data}) do
u = Antidote.decode!(data, keys: :atoms)
u = %{u | password: hash_password("password"), reset_required: true}
with {:ok, _, _, u} <- DB.Users.create(u) do
cors_response(un_struct(u), 201, [{"location", "/api/users/#{u.user_id}"}])
else
e ->
Logger.warn("#{inspect(e, @format)}")
cors_response(400)
end
end
defp proc_request(%{method: :PUT, path: ["api", "users", id], body: data}) do
{:ok, 1, _, u} = DB.Users.get(id)
o = Antidote.decode!(data, keys: :atoms)
{password, reset} =
if o.password === "" or o.password === "0000000000" do
{u.password, o.reset_required}
else
{hash_password(o.password), false}
end
o = %{o | password: password, reset_required: reset}
run(200, DB.Users.update(o))
end
defp proc_request(%{method: :DELETE, path: ["api", "users", id]}), do: run(204, DB.Users.delete(id))
## fallback
defp proc_request(%{path: ["api"]}), do: cors_response("api-root")
defp proc_request(%{path: ["api" | _]}), do: cors_response(404)
## internal
## Security
def hash_password(password) do
# create a unique salt and salt string
salt = :crypto.strong_rand_bytes(16)
salt_string = Base.encode64(salt)
# create the hash passing in the password, the salt, the hmac, the number of iterations and the bytes to be returned
hash_bytes = pbkdf2(password, salt)
hash = Base.encode64(hash_bytes)
"#{salt_string}|#{hash}"
end
def verify_password(hashed_password, provided_password) do
# split the hash from the salt, like we stored it before
[stored_salt, stored_hash] = String.split(hashed_password, "|")
# get the byte arrays of the strings
salt_bytes = Base.decode64!(stored_salt)
# create the hash exactly how we did before, but with the stored salt
calc_hash_bytes = pbkdf2(provided_password, salt_bytes)
# string calc_hash = Convert.ToBase64String(calc_hash_bytes);
calc_hash = Base.encode64(calc_hash_bytes)
{:ok, calc_hash === stored_hash}
end
defp pbkdf2(password, salt), do: pbkdf2(password, salt, :sha256, 20000, 32, 1, [], 0)
defp pbkdf2(_password, _salt, _digest, _rounds, dklen, _block_index, acc, length) when length >= dklen do
key = acc |> Enum.reverse |> IO.iodata_to_binary
<<bin::binary-size(dklen), _::binary>> = key
bin
end
defp pbkdf2(password, salt, digest, rounds, dklen, block_index, acc, length) do
initial = :crypto.hmac(digest, password, <<salt::binary, block_index::integer-size(32)>>)
block = iterate(password, digest, rounds - 1, initial, initial)
pbkdf2(password, salt, digest, rounds, dklen, block_index + 1,
[block | acc], byte_size(block) + length)
end
defp iterate(_password, _digest, 0, _prev, acc), do: acc
defp iterate(password, digest, round, prev, acc) do
next = :crypto.hmac(digest, password, prev)
iterate(password, digest, round - 1, next, :crypto.exor(next, acc))
end
defp generate_token(user_id) do
JsonWebToken.sign(%{user_id: user_id, jti: UUID.uuid4()}, %{key: @jwt_key})
end
## utility
defp un_struct(l) when is_list(l), do: Enum.map(l, &un_struct/1)
defp un_struct(s = %_{}), do: Map.from_struct(s)
defp un_struct(s), do: s
defp cors_response(code) when is_atom(code), do: cors_response(nil, code)
defp cors_response(code) when is_integer(code), do: cors_response(nil, code)
defp cors_response(body, code \\ :ok, headers \\ [])
defp cors_response(body, code, headers), do: api_response(body, code, headers ++ [{"access-control-allow-origin", "*"}, {"access-control-allow-credentials", "true"}])
defp api_response(body, code, headers) when is_list(headers) do
response(code)
|> set_header("content-type", "application/json; charset=utf-8")
|> set_headers(headers)
|> check_body(body)
end
defp sse_response(body, headers) do
response(200)
|> set_headers(headers ++ [{"access-control-allow-credentials", "true"}, {"content-type", "text/event-stream"}])
|> set_body(body)
end
defp check_body(res, nil), do: res
defp check_body(res, b) when is_binary(b), do: set_body(res, b)
defp check_body(res, b), do: set_body(res, Antidote.encode!(b))
defp run(code, {:ok, _count, _durn, nil}), do: cors_response(code)
defp run(code, {:ok, _count, _durn, value}), do: cors_response(un_struct(value), code)
defp run(_code, {:error, msg, _sql}), do: cors_response(%{error_msg: msg},400)
defp run(_code, {:error, x}), do: cors_response(%{error_msg: "#{inspect x}"},400)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment