summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorMike Delago <32778141+michaeldelago@users.noreply.github.com>2023-06-27 13:44:01 -0400
committerGitHub <noreply@github.com>2023-06-27 19:44:01 +0200
commit1480eba9d47bcc9976cfc4aef6b44ee08f79a494 (patch)
treefd0a0acec726b6b17ccdd259b6cc97e143e695f1 /apps
parent7cd575dbb54efc0d8fb10e2364f6f410dc3df9a0 (diff)
feat(Apps): Account creation script that isn't worldserver dependent (#14774)
* inital account-create * fix type * account-create add gmlevel * comments and readme * remove un-used gitignore
Diffstat (limited to 'apps')
-rw-r--r--apps/account-create/.formatter.exs7
-rw-r--r--apps/account-create/Dockerfile10
-rw-r--r--apps/account-create/README.md102
-rw-r--r--apps/account-create/account.exs134
-rw-r--r--apps/account-create/srp.exs59
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