diff options
-rw-r--r-- | apps/account-create/.formatter.exs | 7 | ||||
-rw-r--r-- | apps/account-create/Dockerfile | 10 | ||||
-rw-r--r-- | apps/account-create/README.md | 102 | ||||
-rw-r--r-- | apps/account-create/account.exs | 134 | ||||
-rw-r--r-- | apps/account-create/srp.exs | 59 |
5 files changed, 312 insertions, 0 deletions
diff --git a/apps/account-create/.formatter.exs b/apps/account-create/.formatter.exs new file mode 100644 index 0000000000..921beec593 --- /dev/null +++ b/apps/account-create/.formatter.exs @@ -0,0 +1,7 @@ +# Used by "mix format" +[ + inputs: [ + ".formatter.exs", + "account.exs" + ] +] diff --git a/apps/account-create/Dockerfile b/apps/account-create/Dockerfile new file mode 100644 index 0000000000..4a2537bbfc --- /dev/null +++ b/apps/account-create/Dockerfile @@ -0,0 +1,10 @@ +FROM elixir:1.14-slim + +RUN mix local.hex --force && \ + mix local.rebar --force + +COPY account.exs /account.exs +COPY srp.exs /srp.exs +RUN chmod +x /account.exs + +CMD /account.exs diff --git a/apps/account-create/README.md b/apps/account-create/README.md new file mode 100644 index 0000000000..37380e519c --- /dev/null +++ b/apps/account-create/README.md @@ -0,0 +1,102 @@ +# Account.exs + +Simple script to create an account for AzerothCore + +This script allows a server admin to create a user automatically when after the `dbimport` tool runs, without needed to open up the `worldserver` console. + +## How To Use + +### Pre-requisites + +- MySQL is running +- The authserver database (`acore_auth`, typically) has a table named `account` + +### Running + +```bash +$ elixir account.exs +``` + +### Configuration + +This script reads from environment variables in order to control which account it creates and the MySQL server it's communicating with + + +- `ACORE_USERNAME` Username for account, default "admin" +- `ACORE_PASSWORD` Password for account, default "admin" +- `ACORE_GM_LEVEL` GM Level for account, default 3 +- `MYSQL_DATABASE` Database name, default "acore_auth" +- `MYSQL_USERNAME` MySQL username, default "root" +- `MYSQL_PASSWORD` MySQL password, default "password" +- `MYSQL_PORT` MySQL Port, default 3306 +- `MYSQL_HOST` MySQL Host, default "localhost" + +To use these environment variables, execute the script like so: + +```bash +$ MYSQL_HOST=mysql \ + MYSQL_PASSWORD="fourthehoard" \ + ACORE_USERNAME=drekthar \ + ACORE_PASSWORD=securepass22 \ + elixir account.exs +``` + +This can also be used in a loop. Consider this csv file: + +```csv +user,pass,gm_level +admin,adminpass,2 +soapuser,soappass,3 +mainuser,userpass,0 +``` + +You can then loop over this csv file, and manage users like so: + +```bash +$ while IFS=, read -r user pass gm; do + ACORE_USERNAME=$user \ + ACORE_PASSWORD=$pass \ + GM_LEVEL=$gm \ + elixir account.exs + done <<< $(tail -n '+2' users.csv) +``` + +### Docker + +Running and building with docker is simple: + +```bash +$ docker build -t acore/account-create . +$ docker run \ + -e MYSQL_HOST=mysql \ + -v mix_cache:/root/.cache/mix/installs \ + acore/account-create +``` + +Note that the `MYSQL_HOST` is required to be set with the docker container, as the default setting targets `localhost`. + +### docker-compose + +A simple way to integrate this into a docker-compose file. + +This is why I wrote this script - an automatic way to have an admin account idempotently created on startup of the server. + +```yaml +services: + account-create: + image: acore/account-create:${DOCKER_IMAGE_TAG:-master} + build: + context: apps/account-create/ + dockerfile: apps/account-create/Dockerfile + environment: + MYSQL_HOST: ac-database + MYSQL_PASSWORD: ${DOCKER_DB_ROOT_PASSWORD:-password} + ACORE_USERNAME: ${ACORE_ROOT_ADMIN_ACCOUNT:-admin} + ACORE_PASSWORD: ${ACORE_ROOT_ADMIN_PASSWORD:-password} + volumes: + - mix_cache:/root/.cache/mix/installs + profiles: [local, app, db-import-local] + depends_on: + ac-db-import: + condition: service_completed_successfully +``` diff --git a/apps/account-create/account.exs b/apps/account-create/account.exs new file mode 100644 index 0000000000..c3443025c2 --- /dev/null +++ b/apps/account-create/account.exs @@ -0,0 +1,134 @@ +#!/usr/bin/env elixir +# Execute this Elixir script with the below command +# +# $ ACORE_USERNAME=foo ACORE_PASSWORD=barbaz123 elixir account.exs +# +# Set environment variables for basic configuration +# +# ACORE_USERNAME - Username for account, default "admin" +# ACORE_PASSWORD - Password for account, default "admin" +# ACORE_GM_LEVEL - GM level for account +# MYSQL_DATABASE - Database name, default "acore_auth" +# MYSQL_USERNAME - MySQL username, default "root" +# MYSQL_PASSWORD - MySQL password, default "password" +# MYSQL_PORT - MySQL Port, default 3306 +# MYSQL_HOST - MySQL Host, default "localhost" + +# Install remote dependencies +[ + {:myxql, "~> 0.6.0"} +] +|> Mix.install() + +# Start the logger +Application.start(:logger) +require Logger + +# Constants +default_credential = "admin" +default_gm_level = "3" +account_access_comment = "Managed via account-create script" + +# Import srp functions +Code.require_file("srp.exs", Path.absname(__DIR__)) + +# Assume operator provided a "human-readable" name. +# The database stores usernames in all caps +username_lower = + System.get_env("ACORE_USERNAME", default_credential) + |> tap(&Logger.info("Account to create: #{&1}")) + +username = String.upcase(username_lower) + +password = System.get_env("ACORE_PASSWORD", default_credential) + +gm_level = System.get_env("ACORE_GM_LEVEL", default_gm_level) |> String.to_integer() + +if Range.new(0, 3) |> Enum.member?(gm_level) |> Kernel.not do + Logger.info("Valid ACORE_GM_LEVEL values are 0, 1, 2, and 3. The given value was: #{gm_level}.") +end + +{:ok, pid} = + MyXQL.start_link( + protocol: :tcp, + database: System.get_env("MYSQL_DATABASE", "acore_auth"), + username: System.get_env("MYSQL_USERNAME", "root"), + password: System.get_env("MYSQL_PASSWORD", "password"), + port: System.get_env("MYSQL_PORT", "3306") |> String.to_integer(), + hostname: System.get_env("MYSQL_HOST", "localhost") + ) + +Logger.info("MySQL connection created") + +Logger.info("Checking database for user #{username_lower}") + +# Check if user already exists in database +{:ok, result} = MyXQL.query(pid, "SELECT salt FROM account WHERE username=?", [username]) + +%{salt: salt, verifier: verifier} = + case result do + %{rows: [[salt | _] | _]} -> + Logger.info("Salt for #{username_lower} found in database") + # re-use the salt if the user exists in database + Srp.generate_stored_values(username, password, salt) + _ -> + Logger.info("Salt not found in database for #{username_lower}. Generating a new one") + Srp.generate_stored_values(username, password) + end + +Logger.info("New salt and verifier generated") + +# Insert values into DB, replacing the verifier if the user already exists +result = + MyXQL.query( + pid, + """ + INSERT INTO account + (`username`, `salt`, `verifier`) + VALUES + (?, ?, ?) + ON DUPLICATE KEY UPDATE verifier=? + """, + [username, salt, verifier, verifier] + ) + +case result do + {:error, %{message: message}} -> + File.write("fail.log", message) + + Logger.info( + "Account #{username_lower} failed to create. You can check the error message at fail.log." + ) + + exit({:shutdown, 1}) + + # if num_rows changed and last_insert_id == 0, it means the verifier matched. No change necessary + {:ok, %{num_rows: 1, last_insert_id: 0}} -> + Logger.info( + "Account #{username_lower} doesn't need to have its' password changed. You should be able to log in with that account" + ) + + {:ok, %{num_rows: 1}} -> + Logger.info( + "Account #{username_lower} has been created. You should now be able to login with that account" + ) + + {:ok, %{num_rows: 2}} -> + Logger.info( + "Account #{username_lower} has had its' password reset. You should now be able to login with that account" + ) +end + +# Set GM level to configured value +{:ok, _} = + MyXQL.query( + pid, + """ + INSERT INTO account_access + (`id`, `gmlevel`, `comment`) + VALUES + ((SELECT id FROM account WHERE username=?), ?, ?) + ON DUPLICATE KEY UPDATE gmlevel=?, comment=? + """, [username, gm_level, account_access_comment, gm_level, account_access_comment]) + +Logger.info("GM Level for #{username_lower} set to #{gm_level}") diff --git a/apps/account-create/srp.exs b/apps/account-create/srp.exs new file mode 100644 index 0000000000..987a6eeb99 --- /dev/null +++ b/apps/account-create/srp.exs @@ -0,0 +1,59 @@ +defmodule Srp do + # Constants required in WoW's SRP6 implementation + @n <<137, 75, 100, 94, 137, 225, 83, 91, 189, 173, 91, 139, 41, 6, 80, 83, 8, 1, 177, 142, 191, + 191, 94, 143, 171, 60, 130, 135, 42, 62, 155, 183>> + @g <<7>> + @field_length 32 + + # Wrapper function + def generate_stored_values(username, password, salt \\ "") do + default_state() + |> generate_salt(salt) + |> calculate_x(username, password) + |> calculate_v() + end + + def default_state() do + %{n: @n, g: @g} + end + + # Generate salt if it's not defined + def generate_salt(state, "") do + salt = :crypto.strong_rand_bytes(32) + Map.merge(state, %{salt: salt}) + end + + # Don't generate salt if it's already defined + def generate_salt(state, salt) do + padded_salt = pad_binary(salt) + Map.merge(state, %{salt: padded_salt}) + end + + # Get h1 + def calculate_x(state, username, password) do + hash = :crypto.hash(:sha, String.upcase(username) <> ":" <> String.upcase(password)) + x = reverse(:crypto.hash(:sha, state.salt <> hash)) + Map.merge(state, %{x: x, username: username}) + end + + # Get h2 + def calculate_v(state) do + verifier = + :crypto.mod_pow(state.g, state.x, state.n) + |> reverse() + |> pad_binary() + + Map.merge(state, %{verifier: verifier}) + end + + defp pad_binary(blob) do + pad = @field_length - byte_size(blob) + <<blob::binary, 0::pad*8>> + end + + defp reverse(binary) do + binary + |> :binary.decode_unsigned(:big) + |> :binary.encode_unsigned(:little) + end +end |