lib/auth_web/controllers/auth_controller.ex

defmodule AuthWeb.AuthController do
  @moduledoc """
  Defines AuthController and all functions for authenticaiton
  """
  use AuthWeb, :controller
  alias Auth.App
  alias Auth.Person

  # https://github.com/dwyl/auth/issues/46
  def admin(conn, _params) do
    render(conn, :welcome, apps: App.list_apps(conn.assigns.person.id))
  end

  # first we check for referer and auth_client_id in query parameters
  # This means a consumer app attempt to authenticate
  # we display the login buttons
  def index(%{query_params: %{"auth_client_id" => client_id}} = conn, params) do
    valid_client_id = client_id && client_id_valid?(client_id, conn)
    log_auth(conn, params, client_id, valid_client_id)

    if valid_client_id do
      render_login_buttons(conn, params)
    else
      error_message = "client_id: #{client_id} is not valid"

      conn
      |> put_flash(:error, error_message)
      |> unauthorized(error_message)
    end
  end

  # Handle requests where already authenticated: github.com/dwyl/auth/issues/69
  # This is used for the auth app only as consumer app won't create a session
  # in the auth app
  def index(%{assigns: %{person: person}} = conn, _params) do
    redirect_or_render(conn, person, nil)
  end

  # Login for auth app
  def index(conn, params) do
    log_auth(conn, params, nil, nil)
    render_login_buttons(conn, params)
  end

  # log authentication
  # client_id is not defined as query parameter, ie auth on Auth app
  defp log_auth(conn, params, nil, _), do: Auth.Log.info(conn, params)

  # client_id defined and valid
  defp log_auth(conn, params, client_id, true) do
    msg = "request with client_id: #{client_id} index in auth controller"
    Auth.Log.info(conn, Map.merge(params, %{msg: msg}))
  end

  # client_id not valid
  defp log_auth(conn, params, client_id, false) do
    msg = "auth_client_id: #{client_id} is not valid index in auth controller"
    Auth.Log.error(conn, Map.merge(params, %{msg: msg}))
  end

  # render the login page with appropriate redirections
  def render_login_buttons(conn, _params) do
    # create referer to consumer app or auth app
    referer = get_referer(conn)
    oauth_github_url = ElixirAuthGithub.login_url(%{scopes: ["user:email"], state: referer})
    oauth_google_url = ElixirAuthGoogle.generate_oauth_url(conn, referer)

    conn
    # |> Auth.Log.info(Map.merge(params, %{msg: "render_login_buttons:95 state: #{state}"}))
    |> assign(:action, Routes.auth_path(conn, :login_register_handler))
    |> render("index.html",
      oauth_github_url: oauth_github_url,
      oauth_google_url: oauth_google_url,
      changeset: Auth.Person.login_register_changeset(%{}),
      state: referer
    )
  end

  # confirm that the client_id is valid for the app:
  # returns the app_id from client_id as app_id is used to create client id and secret
  def client_id_valid?(client_id, conn) do
    # attempt to decrypt the client_id
    case Auth.Apikey.decode_decrypt(client_id) do
      # if auth_client_id in the URL but we cannot decrypt it, reject early!
      # see: https://github.com/dwyl/auth/issues/129
      {:error, _} ->
        msg = "client_id_valid?:109 Unable to decrypt auth_client_id:#{client_id}"
        Auth.Log.error(conn, %{msg: msg})
        false

      # able to decrypt the client_id to an app_id check if still valid:
      {:ok, app_id} ->
        client_id_is_current?(app_id, client_id)
    end
  end

  # return true if the app_id is linked to the client_id otherwise false
  # Check if the api key hasn't been deleted
  defp client_id_is_current?(app_id, client_id) do
    case Auth.Apikey.get_apikey_by_app_id(app_id) do
      nil ->
        false

      apikey ->
        apikey.client_id == client_id
    end
  end

  # do not append the client id if it doesn't exist, see #135
  def append_client_id(referer, nil), do: referer
  def append_client_id(referer, client_id), do: "#{referer}?auth_client_id=#{client_id}"

  def get_referer(conn) do
    case conn.query_string =~ "referer" do
      true ->
        query = URI.decode_query(conn.query_string)
        ref = Map.get(query, "referer")
        client_id = get_client_id_from_query(conn)
        ref |> URI.encode() |> append_client_id(client_id)

      #  no referer, redirect back to Auth app.
      false ->
        (AuthPlug.Helpers.get_baseurl_from_conn(conn) <> "/profile")
        |> URI.encode()
        |> append_client_id(AuthPlug.Token.client_id())
    end
  end

  # returns the auth_client_id or nil if it doesn't exist in the query
  defp get_client_id_from_query(conn), do: conn.query_params["auth_client_id"]

  @doc """
  `github_auth/2` handles the callback from GitHub Auth API redirect.
  """
  def github_handler(conn, %{"code" => code, "state" => state}) do
    {:ok, profile} = ElixirAuthGithub.github_auth(code)
    app_id = get_app_id(state)

    # save profile to people:
    person = Person.create_github_person(Map.merge(profile, %{app_id: app_id}))

    # render or redirect:
    handler(conn, person, state)
  end

  def get_app_id(state) do
    client_secret =
      state
      |> get_client_secret_from_state()
      |> Auth.Apikey.decode_decrypt()

    case client_secret do
      {:ok, app_id} -> app_id
      {:error, _} -> 1
    end
  end

  @doc """
  `google_handler/2` handles the callback from Google Auth API redirect.
  """
  def google_handler(conn, %{"code" => code, "state" => state}) do
    {:ok, token} = ElixirAuthGoogle.get_token(code, conn)
    {:ok, profile} = ElixirAuthGoogle.get_user_profile(token.access_token)
    # save profile to people:
    app_id = get_app_id(state)
    person = Person.create_google_person(Map.merge(profile, %{app_id: app_id}))

    # render or redirect:
    handler(conn, person, state)
  end

  @doc """
  `handler/3` responds to successful auth requests.
  if the state is defined, redirect to it.
  """
  def handler(conn, person, state) do
    # Send welcome email: temporarily disabled to avoid noise.
    # Auth.Email.sendemail(%{
    #   email: person.email,
    #   name: person.givenName,
    #   template: "welcome"
    # })
    redirect_or_render(conn, person, state)
  end

  @doc """
  `redirect_or_render/3` does what it's name suggests,
  redirects if the `state` (HTTP referer) is defined
  or renders the default `:welcome` template.
  If the `auth_client_id` is undefined or invalid,
  render the `unauthorized/1` 401.
  """
  def redirect_or_render(conn, person, state) do
    # create the session in the sessions table:
    conn = Auth.Session.start_session(conn, person)
    # check if valid state (HTTP referer) is defined:
    if is_nil(state) or state == "" do
      # No State > Display Welcome page on Auth site:
      conn
      |> AuthPlug.create_jwt_session(session_data(person, conn.assigns.sid))
      |> Auth.Log.info(%{status_id: 200, app_id: 1})
      |> render(:welcome, person: person, apps: App.list_apps(person.id))
    else
      # State > Redirect to requesting app:
      case get_client_secret_from_state(state) do
        0 ->
          unauthorized(conn, "invalid AUTH_API_KEY")

        secret ->
          conn
          |> Auth.Log.info(%{status_id: 200, app_id: get_app_id(state)})
          |> redirect(external: add_jwt_url_param(person, conn.assigns.sid, state, secret))
      end
    end
  end

  def error(conn, msg, status) do
    # https://hexdocs.pm/phoenix/Phoenix.Controller.html#get_format/1
    if get_format(conn) == "json" do
      data = %{status_id: status, msg: msg}

      conn
      |> Auth.Log.error(data)
      |> put_status(status)
      |> json(data)
    else
      conn
      |> Auth.Log.error(%{status_id: status, msg: msg})
      |> put_status(status)
      |> assign(:reason, %{message: msg})
      |> put_view(AuthWeb.ErrorView)
      |> render("404.html", conn: conn)
    end
  end

  def unauthorized(conn, msg \\ "invalid AUTH_API_KEY/client_id please check") do
    error(conn, msg, 401)
  end

  def not_found(conn, msg) do
    error(conn, msg, 404)
  end

  @doc """
  `login_register_handler/2` is a hybrid of traditional registration and login.
  If the person has already registered, we treat it as a login attempt and
  present them with a password field/form to complete.
  If the person does *not* exist (or has not yet verified their email address),
  we show them a welcome screen informing them that a verification email
  was sent to their address. When they click it they will see a password (reset)
  form where they can define a new password for their account.
  """
  def login_register_handler(conn, params) do
    p = params["person"]
    email = p["email"]
    state = p["state"]
    app_id = get_app_id(state)

    # email is blank or invalid:
    if is_nil(email) or not Fields.Validate.email(email) do
      Auth.Log.error(conn, %{email: email, app_id: app_id, status_id: 401, msg: "email invalid"})

      # email invalid, re-render the login/register form:
      index(conn, params)
    else
      person = Auth.Person.get_person_by_email(email)

      # check if the email exists in the people table:
      person =
        if is_nil(person) do
          person =
            Auth.Person.create_person(%{
              email: email,
              auth_provider: "email"
            })

          Auth.Email.sendemail(%{
            email: email,
            template: "verify",
            link: make_verify_link(conn, person, state),
            subject: "Please Verify Your Email Address"
          })

          person
        else
          person
        end

      password_form(conn, person, state)
    end
  end

  # setup password form depending on person values
  defp password_form(conn, person, state) do
    cond do
      is_nil(person.status) and is_nil(person.password_hash) ->
        # person has not verified their email address or created a password
        # TODO: pull out these messages into a translateable file.
        message = """
        You registered with the email address: #{person.email}. An email was sent
        to you with a link to confirm your address. Please check your email
        inbox for our message, open it and click the link.
        """

        render_password_form(conn, person.email, message, state, "password_create")

      person.status > 0 and is_nil(person.password_hash) ->
        # has verified but not yet defined a password
        render_password_form(conn, person.email, "", state, "password_create")

      is_nil(person.status) and not is_nil(person.password_hash) ->
        # person has not yet verified their email but has defined a password
        message = """
        You registered with the email address: #{person.email}. An email was sent
        to you with a link to confirm your address. Please check your email
        inbox for our message, open it and click the link.
        You can still login using the password you saved.
        """

        render_password_form(conn, person.email, message, state, "password_prompt")

      person.status > 0 and not is_nil(person.password_hash) ->
        # render password prompt without any put_flash message
        render_password_form(conn, person.email, "", state, "password_prompt")
    end
  end

  def render_password_form(conn, email, message, state, template) do
    conn
    |> put_flash(:info, message)
    |> assign(:action, Routes.auth_path(conn, String.to_atom(template)))
    |> render(template <> ".html",
      changeset: Auth.Person.password_new_changeset(%{email: email}),
      # so we can redirect after creatig a password
      state: state,
      email: Auth.Apikey.encrypt_encode(email)
    )
  end

  @doc """
  `make_verify_link/3` creates a verfication link that gets included
  in the email we send to people to verify their email address.
  The person.id is encrypted and base58 encoded to avoid anyone verifying
  a different person's email. (not that anyone would do that, right? ;-)
  We include the original state (HTTP referer) so that the request can be
  redirected back to the desired page on successful verification.
  """
  def make_verify_link(conn, person, state) do
    AuthPlug.Helpers.get_baseurl_from_conn(conn) <>
      "/auth/verify?id=" <>
      Auth.Apikey.encrypt_encode(person.id) <>
      "&referer=" <> state
  end

  @doc """
  `password_create/2` is called when a new person is registering with email
  and is defining a password for the first time.
  Note: at present we are not enforcing any rules for password strength/length.
  Thinking of doing these checks as progressive enhancement in Browser.
  see:
  """
  def password_create(conn, params) do
    p = params["person"]
    email = Auth.Person.decrypt_email(p["email"])
    changeset = Auth.Person.password_new_changeset(%{email: email, password: p["password"]})

    if changeset.valid? do
      person = Auth.Person.upsert_person(%{email: email, password: p["password"]})
      # replace %Auth.Role{} struct with string  github.com/dwyl/rbac/issues/4
      person = Map.replace!(person, :roles, RBAC.transform_role_list_to_string(person.roles))
      redirect_or_render(conn, person, p["state"])
    else
      conn
      |> assign(:action, Routes.auth_path(conn, :password_create))
      |> render("password_create.html",
        changeset: changeset,
        state: p["state"],
        email: p["email"]
      )
    end
  end

  @doc """
  `password_prompt/2` handles all requests to verify a password for a person.
  If the pasword is verified (using Argon2.verify_pass), redirect to their
  desired page. If the password is invalid reset & re-render the form.
  """
  def password_prompt(conn, params) do
    p = params["person"]
    email = Auth.Person.decrypt_email(p["email"])
    person = Auth.Person.get_person_by_email(email)
    state = p["state"]
    app_id = get_app_id(state)

    case Argon2.verify_pass(p["password"], person.password_hash) do
      true ->
        redirect_or_render(conn, person, p["state"])

      false ->
        msg = """
        That password is incorrect.
        """

        Auth.Log.error(conn, %{email: email, app_id: app_id, status_id: 401})
        render_password_form(conn, email, msg, p["state"], "password_prompt")
    end
  end

  def verify_email(conn, params) do
    case Auth.Apikey.decode_decrypt(params["id"]) do
      {:ok, id} ->
        person = Auth.Person.verify_person_by_id(id)
        redirect_or_render(conn, person, params["referer"])

      {:error, _} ->
        unauthorized(conn, "INVALID AUTH_API_KEY")
    end
  end

  def get_client_id_from_state(state) do
    state
    |> String.split("?")
    |> List.last()
    |> URI.decode_query()
    |> Map.get("auth_client_id")
  end

  @doc """
  `get_client_secret_from_state/1` gets the client_id from state,
  attempts to decode_decrypt it and then look it up in apikeys
  if it finds the corresponding client_secret it returns the client_secret.
  All other failure conditions return a 0 (zero) which results in a 401.
  """
  def get_client_secret_from_state(state) do
    client_id = get_client_id_from_state(state)

    # Lookup client_id in apikeys table
    # or state without client_id is not valid
    if is_nil(client_id), do: 0, else: get_client_secret(client_id, state)
  end

  def get_client_secret(client_id, state) do
    # decode_decrypt fails with 0:
    case Auth.Apikey.decode_decrypt(client_id) do
      {:error, _} ->
        0

      {:ok, app_id} ->
        apikey = Auth.Apikey.get_apikey_by_app_id(app_id)

        cond do
          # if the apikey isn't found it will be nil
          is_nil(apikey) ->
            0

          apikey.app.person_id == 1 ->
            apikey.client_secret

          # all other keys require matching the app url and status to not be "deleted":
          apikey.client_id == client_id && state =~ apikey.app.url && apikey.status != 6 ->
            apikey.client_secret

          true ->
            0
        end
    end
  end

  def session_data(person, sid) do
    roles =
      if Map.has_key?(person, :roles) do
        RBAC.transform_role_list_to_string(person.roles)
      else
        nil
      end

    %{
      auth_provider: person.auth_provider,
      givenName: person.givenName,
      id: person.id,
      picture: person.picture,
      status: person.status,
      email: person.email,
      roles: roles,
      app_id: person.app_id,
      sid: sid
    }
  end

  def add_jwt_url_param(person, sid, state, client_secret) do
    jwt = AuthPlug.Token.generate_jwt!(session_data(person, sid), client_secret)

    List.first(String.split(URI.decode(state), "?")) <>
      "?jwt=" <> jwt
  end

  @doc """
  `logout/2` logs the person out of their session and destroys cookie.
  """
  def logout(conn, params) do
    conn
    # |> Auth.Session.end_session()
    |> AuthPlug.logout()
    |> put_flash(:info, "Successfully logged out.")
    |> index(params)
  end
end