diff --git a/apps/server/lib/prodigy/server/service/logoff.ex b/apps/server/lib/prodigy/server/service/logoff.ex index d02321f..89480ab 100644 --- a/apps/server/lib/prodigy/server/service/logoff.ex +++ b/apps/server/lib/prodigy/server/service/logoff.ex @@ -1,4 +1,4 @@ -# Copyright 2022, Phillip Heller +# Copyright 2022-2025, Phillip Heller # # This file is part of Prodigy Reloaded. # @@ -14,75 +14,176 @@ # see . defmodule Prodigy.Server.Service.Logoff do - @behaviour Prodigy.Server.Service @moduledoc """ Handle Logoff requests """ + @behaviour Prodigy.Server.Service + require Logger require Ecto.Query + use EnumType + import Ecto.Changeset + import Ecto.Query, only: [from: 2] alias Prodigy.Core.Data.{Repo, User} - alias Prodigy.Server.Protocol.Dia.Packet, as: DiaPacket + alias Prodigy.Server.Protocol.Dia.Packet alias Prodigy.Server.Protocol.Dia.Packet.Fm0 alias Prodigy.Server.Context alias Prodigy.Server.SessionManager - defp clear_id_in_use(user_id, reason \\ "normal") do - user = - User - |> Ecto.Query.where([u], u.id == ^user_id) - |> Ecto.Query.first() - |> Repo.one() + @normal_logoff_dest 0x00D202 + @disconnect_logoff_dest 0x00D201 + + defenum Status do + @moduledoc "An enumeration of Logoff service responses" + + value SUCCESS, 0x0 + end + + @doc """ + Handles incoming logoff requests + """ + def handle(%Fm0{} = request, %Context{user: nil}) do + # No user in context - just send empty response + response = build_empty_response(request) + {:ok, %Context{}, response} + end + + def handle(%Fm0{} = request, %Context{user: user} = context) do + request + |> parse_request(user) + |> and_then(&update_last_logon_time/1) + |> and_then(&end_session/1) + |> and_then(&determine_result/1) + |> and_then(&build_response/1) + |> finalize_response(context) + end + @doc """ + Handles abnormal disconnection (e.g., connection drop) + """ + def handle_abnormal(nil), do: :ok + + def handle_abnormal(user) do + {:ok, %{user: user, reason: :abnormal, request: nil}} + |> update_last_logon_time() + |> and_then(&end_session/1) + + :ok + end + + # Railway-oriented combinator - handles any :ok tuple + defp and_then({:error, _} = error, _func), do: error + defp and_then(success_tuple, func) when elem(success_tuple, 0) == :ok, do: func.(success_tuple) + + # Pipeline functions + + defp parse_request(request, user) do + reason = case request.dest do + @disconnect_logoff_dest -> :disconnect + @normal_logoff_dest -> :normal + _ -> :normal + end + + {:ok, %{user: user, reason: reason, request: request}} + end + + defp update_last_logon_time({:ok, %{user: user} = state}) do # Because some pages specifically say "Eastern" now = Timex.now() |> Timex.Timezone.convert("US/Eastern") - user - |> change(%{ - prf_last_logon_date: Timex.format!(now, "{0M}/{0D}/{YYYY}"), - prf_last_logon_time: Timex.format!(now, "{h24}.{m}") - }) - |> Repo.update() - - # Close the connection - status = case reason do - "normal" -> :normal - "abnormal" -> :abnormal - _ -> :abnormal + last_logon_date = Timex.format!(now, "{0M}/{0D}/{YYYY}") + last_logon_time = Timex.format!(now, "{h24}.{m}") + + result = Repo.transaction(fn -> + fetch_user_for_update(user.id) + |> change(%{ + prf_last_logon_date: last_logon_date, + prf_last_logon_time: last_logon_time + }) + |> Repo.update!() + end) + + case result do + {:ok, _updated_user} -> + {:ok, state} + {:error, _reason} -> + # Log but don't fail the logoff + Logger.warning("Failed to update last logon time for user #{user.id}") + {:ok, state} end + end - SessionManager.close_session(user_id, status) - Logger.info("User #{user_id} logged off (#{reason})") - :ok + defp end_session({:ok, %{user: user, reason: reason} = state}) do + session_status = case reason do + :normal -> :normal + :disconnect -> :normal + :abnormal -> :abnormal + end + + SessionManager.close_session(user.id, session_status) + + log_reason = case reason do + :disconnect -> "Disconnect" + :normal -> "Normal" + :abnormal -> "Abnormal" + end + + Logger.info("User #{user.id} logged off (#{log_reason})") + + {:ok, state} end - def handle(%Fm0{} = request, %Context{user: nil}) do - {:ok, %Context{}, DiaPacket.encode(Fm0.make_response(<<>>, request))} + defp determine_result({:ok, %{reason: reason} = state}) do + result = case reason do + :disconnect -> :disconnect + _ -> :ok + end + + {:ok, Map.put(state, :result, result)} + end + + defp build_response({:ok, %{request: nil} = state}) do + # Abnormal termination - no response needed + {:ok, state} end - def handle(%Fm0{dest: dest} = request, %Context{user: user}) do - {:ok, result} = - Repo.transaction(fn -> - clear_id_in_use(user.id) + defp build_response({:ok, %{request: request} = state}) do + # TODO: Determine what this timestamp should actually be + response_payload = <> - case dest do - 0x00D201 -> :disconnect - 0x00D202 -> :ok - end - end) + response = response_payload + |> Fm0.make_response(request) + |> Packet.encode() - {result, %Context{auth_timeout: Context.set_auth_timer()}, - DiaPacket.encode(Fm0.make_response(<<0, "xxxxxxxx01011988124510">>, request))} + {:ok, Map.put(state, :response, response)} end - def handle_abnormal(nil) do - :ok + defp finalize_response({:ok, %{result: result, response: response}}, _context) do + new_context = %Context{auth_timeout: Context.set_auth_timer()} + {result, new_context, response} end - def handle_abnormal(user) do - clear_id_in_use(user.id, "abnormal") - :ok + defp finalize_response({:ok, _state}, context) do + # Abnormal termination path - no response + {:ok, context, nil} + end + + # Helper functions + + defp fetch_user_for_update(user_id) do + from(u in User, + where: u.id == ^user_id, + lock: "FOR UPDATE" + ) + |> Repo.one!() + end + + defp build_empty_response(request) do + <<>> + |> Fm0.make_response(request) + |> Packet.encode() end -end +end \ No newline at end of file diff --git a/apps/server/lib/prodigy/server/service/logon.ex b/apps/server/lib/prodigy/server/service/logon.ex index 5d7c107..3f979da 100644 --- a/apps/server/lib/prodigy/server/service/logon.ex +++ b/apps/server/lib/prodigy/server/service/logon.ex @@ -1,4 +1,4 @@ -# Copyright 2022, Phillip Heller +# Copyright 2022-2025, Phillip Heller # # This file is part of Prodigy Reloaded. # @@ -14,16 +14,18 @@ # see . defmodule Prodigy.Server.Service.Logon do - @behaviour Prodigy.Server.Service @moduledoc """ Handle Logon requests """ + @behaviour Prodigy.Server.Service + require Logger require Ecto.Query use EnumType import Prodigy.Core.Util + import Ecto.Query, only: [from: 2] alias Comeonin.Ecto.Password alias Prodigy.Core.Data.{Repo, User} @@ -33,283 +35,304 @@ defmodule Prodigy.Server.Service.Logon do alias Prodigy.Server.SessionManager alias Prodigy.Server.Context + @supported_versions ["06.03.10", "06.03.17"] + defenum Status do @moduledoc "An enumeration of Logon service responses" - value SUCCESS, 0x0 do - @moduledoc false + value SUCCESS, 0x0 + value ENROLL_OTHER, 0x1 + value ENROLL_SUBSCRIBER, 0x2 + value BAD_PASSWORD, 0x5 + value ID_IN_USE, 0x7 + value BAD_VERSION, 0x9 + value ACCOUNT_PROBLEM, 0xD + end + + @doc """ + Handles incoming logon requests + """ + def handle(%Fm0{} = request, %Context{auth_timeout: auth_timeout} = context) do + request + |> parse_request() + |> and_then(&validate_version/1) + |> and_then(&authenticate_user/1) + |> and_then(&validate_user_status/1) + |> and_then(&check_enrollment/1) + |> and_then(&begin_session/1) + |> and_then(&build_response/1) + |> finalize_response(context, auth_timeout) + end + + # Railway-oriented combinator - handles any :ok tuple + defp and_then({:error, _, _} = error, _func), do: error + defp and_then(success_tuple, func) when elem(success_tuple, 0) == :ok, do: func.(success_tuple) + + # Pipeline functions + + defp parse_request(%Fm0{payload: <<_, user_id::binary-size(7), pwlen, password::binary-size(pwlen), + version::binary-size(8), _rest::binary>>} = request) do + {:ok, %{user_id: user_id, password: password, version: version, request: request, user: nil}} + end + + defp parse_request(%Fm0{} = request) do + {:error, Status.ACCOUNT_PROBLEM, request} + end + + defp validate_version({:ok, %{version: version} = state}) when version in @supported_versions do + Logger.debug("User is connecting with RS #{version}") + {:ok, state} + end + + defp validate_version({:ok, %{request: request}}) do + Logger.warning("User is connecting with an unacceptable software version") + {:error, Status.BAD_VERSION, request} + end + + defp authenticate_user({:ok, %{user_id: user_id, password: password, request: request} = state}) do + Logger.debug("Retrieving user '#{user_id}' (password: '#{password}')") + + user = fetch_user_with_associations(user_id) + + case user do + nil -> + Logger.warning("User #{user_id} attempted to logon, but does not exist in the database") + {:error, Status.BAD_PASSWORD, request} + + user -> + case verify_password(user, password) do + :ok -> + {:ok, Map.put(state, :user, user)} + :error -> + Logger.warning("User #{user.id} attempted logon, but failed authentication") + {:error, Status.BAD_PASSWORD, request} + end end + end + + defp validate_user_status({:ok, %{user: user, request: request} = state}) do + cond do + user_deleted?(user) -> + Logger.debug("User is deleted") + {:error, Status.ACCOUNT_PROBLEM, request} + + not household_active?(user) -> + Logger.debug("Household inactive") + {:error, Status.ACCOUNT_PROBLEM, request} - value ENROLL_OTHER, 0x1 do - @moduledoc false + true -> + {:ok, state} end + end + + defp check_enrollment({:ok, %{user: user} = state}) do + normalized_user_id = String.upcase(user.id) + + enrollment = cond do + user.date_enrolled != nil -> + Logger.debug("User is enrolled") + :enrolled - value ENROLL_SUBSCRIBER, 0x2 do - @moduledoc false + String.ends_with?(normalized_user_id, "A") -> + Logger.debug("Subscriber is unenrolled") + :enroll_subscriber + + true -> + Logger.debug("Household member is unenrolled") + :enroll_other end - value BAD_PASSWORD, 0x5 do - @moduledoc false + {:ok, Map.put(state, :enrollment, enrollment)} + end + + defp begin_session({:ok, %{user: user, enrollment: enrollment, version: version, request: request} = state}) do + session_status = case enrollment do + :enrolled -> :success + :enroll_subscriber -> :enroll_subscriber + :enroll_other -> :enroll_other end - value ID_IN_USE, 0x7 do - @moduledoc false + case SessionManager.create_session(user, session_status, version) do + {:ok, session} -> + status = case enrollment do + :enrolled -> Status.SUCCESS + :enroll_subscriber -> Status.ENROLL_SUBSCRIBER + :enroll_other -> Status.ENROLL_OTHER + end + {:ok, state |> Map.put(:status, status) |> Map.put(:session_id, session.id)} + + {:error, :concurrency_exceeded} -> + Logger.warning("User #{user.id} attempted logon, but exceeded concurrency limit") + {:error, Status.ID_IN_USE, request} end + end + + defp build_response({:ok, %{status: status, user: user, session_id: session_id, request: request} = state}) do + response = make_response_payload({status, user, session_id}) + |> Fm0.make_response(request) + |> Packet.encode() + + {:ok, Map.put(state, :response, response)} + end - value BAD_VERSION, 0x9 do - @moduledoc false + defp finalize_response({:ok, %{status: status, user: user, session_id: session_id, version: version, response: response}}, _context, auth_timeout) do + Context.cancel_auth_timer(auth_timeout) + + log_message = case status do + Status.SUCCESS -> "logged on (Normal)" + Status.ENROLL_SUBSCRIBER -> "logged on (Enroll Subscriber)" + Status.ENROLL_OTHER -> "logged on (Enroll Other)" end - value ACCOUNT_PROBLEM, 0xD do - @moduledoc false + Logger.info("User #{user.id} #{log_message}") + + new_context = %Context{ + user: user, + session_id: session_id, + rs_version: version + } + + {:ok, new_context, response} + end + + defp finalize_response({:error, status, request}, context, _auth_timeout) do + response = make_response_payload({status, nil, nil}) + |> Fm0.make_response(request) + |> Packet.encode() + + {:error, context, response} + end + + # Helper functions + + defp fetch_user_with_associations(user_id) do + from(u in User, + where: u.id == ^user_id, + preload: [:household, :data_collection_policy] + ) + |> Repo.one() + end + + defp verify_password(user, password) do + # TODO: Remove this once tooling supports creating users with initial password hashed + encrypted_pw = if String.starts_with?(user.password, "$pbkdf2-sha512$") do + user.password + else + Pbkdf2.hash_pwd_salt(user.password) + end + + if Password.valid?(password, encrypted_pw) do + Logger.debug("Retrieved user with matching password") + :ok + else + :error end end - def make_response_payload({Status.SUCCESS, user, _session_id}) do - now = Calendar.DateTime.now_utc() - # TODO refactor to use Timex - date = now |> Calendar.strftime("%m%d%y") - # TODO refactor to use Timex - time = now |> Calendar.strftime("%H%M%S") + defp user_deleted?(user) do + user.date_deleted != nil + end - Logger.debug("user record: #{inspect(user, limit: :infinity)}") - new_mail_indicator = bool2int(Messaging.unread_messages?(user)) + defp household_active?(user) do + user.household.disabled_date == nil and user.household.enabled_date != nil + end - data_collection = - cond do - Ecto.assoc_loaded?(user.data_collection_policy) == false -> - <<0::32>> - - user.data_collection_policy == nil -> - <<0::32>> - - true -> - # these constants may not be entirely right - see rs-rsk-c.pdf, page # 124 - _dc = user.data_collection_policy - - << - 1::1, - 0::3, - bool2int(user.data_collection_policy.ad)::1, # 'A' - bool2int(user.data_collection_policy.pwindow)::1, # 'W' - bool2int(user.data_collection_policy.element)::1, # 'E' - bool2int(user.data_collection_policy.template)::1, # 'T' - - # function codes - bool2int(user.data_collection_policy.exit)::1, # 'E' - bool2int(user.data_collection_policy.undo)::1, # 'U' - bool2int(user.data_collection_policy.path)::1, # 'P' - bool2int(user.data_collection_policy.help)::1, # 'H' - bool2int(user.data_collection_policy.jump)::1, # 'J' - bool2int(user.data_collection_policy.back)::1, # 'B' - bool2int(user.data_collection_policy.next)::1, # 'N' - bool2int(user.data_collection_policy.commit)::1, # 'C' - 0::6, - bool2int(user.data_collection_policy.action)::1, # 'A' - bool2int(user.data_collection_policy.look)::1, # 'L' - 0::8 - >> - end + # Response payload building - last_logon_date = val_or_else(user.prf_last_logon_date, " ") - last_logon_time = val_or_else(user.prf_last_logon_date, " ") + def make_response_payload({Status.SUCCESS, user, _session_id}) do + {date, time} = get_formatted_datetime() + new_mail_indicator = bool2int(Messaging.unread_messages?(user)) + data_collection = build_data_collection_bits(user) + {last_logon_date, last_logon_time} = get_last_logon_info(user) - res = << + << 0x0, - "M", # TODO set PRF_GENDER from user record, N.B. nil breaks encoding + "M", # TODO: set PRF_GENDER from user record data_collection::binary, 0x0, # unknown 0x0, # PRF_BUSINESS_CLIENT_CODE 0x0, # PRF_USER_SECURITY_LEVEL 0x0, # PRF_AUTO_SKIP 0x0, # PRF_GAME_PROFILE - date::binary-size(6)-unit(8), # SYS_DATE - time::binary-size(6)-unit(8), # SYS_TIME + date::binary-size(6), + time::binary-size(6), new_mail_indicator, 0x0::16, # PRF_CUG_ID 0x0::16, # ?? 0x0::16, # PRF_CUG_SERVICE_ID - 0x0::unsigned-integer-size(16), # PRF_USER_CLASS - - # Additional information expected by TLPEADDS + 0x0::16, # PRF_USER_CLASS 0::16, # PRF_TIER_OF_SERVICE 0::16, # PRF_CCL_VERSION_NUM 0, # PRF_SEL_NUM_ITEMS 0, 0::16, - last_logon_date::binary-size(8), # PRF_LAST_LOGON_DATE - last_logon_time::binary-size(5) # PRF_LAST_LOGON_TIME - # PRF_SEL_ITEM_LIST::2 + last_logon_date::binary-size(8), + last_logon_time::binary-size(5) >> - - Logger.debug("logon response: #{inspect(res, base: :hex, limit: :infinity)}") - res end def make_response_payload({status, _, _}) do - now = Calendar.DateTime.now_utc() - date = now |> Calendar.strftime("%m%d%y") - time = now |> Calendar.strftime("%H%M%S") + {date, time} = get_formatted_datetime() << status.value(), - 0x0::unsigned-integer-size(80), - date::binary-size(6)-unit(8), # SYS_DATE - time::binary-size(6)-unit(8), # SYS_TIME - 0x0::unsigned-integer-size(56) + 0x0::80, + date::binary-size(6), + time::binary-size(6), + 0x0::56 >> end - defp get_user(user_id, password) do - # TODO need to check the household if user_id ends in A and there is no User record - user = - User - |> Ecto.Query.where(id: ^user_id) - |> Ecto.Query.first() - |> Ecto.Query.preload([:household]) - |> Ecto.Query.preload([:data_collection_policy]) - |> Repo.one() - - Logger.debug("retrieving user '#{user_id} (password: '#{password}')") - Logger.debug("#{inspect(user)}") - - if user == nil do - Logger.warning("User #{user_id} attempted to logon, but does not exist in the database") - :bad_password - else - # TODO remove this once tooling supports creating users with initial password hashed - encrypted_pw = - case String.starts_with?(user.password, "$pbkdf2-sha512$") do - true -> user.password - false -> Pbkdf2.hash_pwd_salt(user.password) - end - - case Password.valid?(password, encrypted_pw) do - true -> - Logger.debug("retrieved user with matching password") - {:ok, user} - - false -> - Logger.warning("User #{user_id} attempted logon, but failed authentication") - :bad_password - end - end + defp get_formatted_datetime do + now = Calendar.DateTime.now_utc() + date = Calendar.strftime(now, "%m%d%y") + time = Calendar.strftime(now, "%H%M%S") + {date, time} end - defp enrolled(user) do - normalized_user_id = String.upcase(user.id) - - cond do - # TODO check date_enrolled is today or prior - user.date_enrolled != nil -> - Logger.debug("User is enrolled") - true - - String.ends_with?(normalized_user_id, "A") -> - Logger.debug("Subscriber is unenrolled") - {:enroll_subscriber, user} - - true -> - Logger.debug("Household member is unenrolled") - {:enroll_other, user} - end + defp get_last_logon_info(user) do + last_logon_date = val_or_else(user.prf_last_logon_date, " ") + last_logon_time = val_or_else(user.prf_last_logon_time, " ") + {last_logon_date, last_logon_time} end - defp household_active(user) do - # TODO check enabled_date is today or earlier and disabled_date is nil or after today - if user.household.disabled_date == nil and user.household.enabled_date != nil do - true - else - Logger.debug("household inactive") - :account_problem + defp build_data_collection_bits(user) do + policy = cond do + not Ecto.assoc_loaded?(user.data_collection_policy) -> nil + true -> user.data_collection_policy end - end - defp deleted(user) do - # TODO check that date_deleted is nil or after today - if user.date_deleted == nil do - false - else - Logger.debug("User is deleted") - :account_problem - end - end + case policy do + nil -> + <<0::32>> - defp version_ok(version) do - if version in ["06.03.10", "06.03.17"] do - Logger.debug("User is connecting with RS #{version}") - true - else - Logger.warning("User is connecting with an unacceptable software version") - :bad_version + policy -> + encode_data_collection_policy(policy) end end - def handle( - %Fm0{ - payload: - <<_, user_id::binary-size(7)-unit(8), pwlen, password::binary-size(pwlen)-unit(8), - version::binary-size(8)-unit(8), _rest::binary>> - } = request, - %Context{auth_timeout: auth_timeout} = context - ) do - result = - with true <- version_ok(version), - {:ok, user} <- get_user(user_id, password), - false <- deleted(user), - true <- household_active(user), - enrollment_status <- enrolled(user) do - - # create context based on enrollment status - status = case enrollment_status do - true -> :success - {:enroll_subscriber, _} -> :enroll_subscriber - {:enroll_other, _} -> :enroll_other - end - - case SessionManager.create_session(user, status, version) do - {:ok, db_session} -> - case enrollment_status do - true -> {Status.SUCCESS, user, db_session.id} - {:enroll_subscriber, user} -> {Status.ENROLL_SUBSCRIBER, user, db_session.id} - {:enroll_other, user} -> {Status.ENROLL_OTHER, user, db_session.id} - end - {:error, :concurrency_exceeded} -> - Logger.warning("User #{user.id} attempted logon, but exceeded concurrency limit") - {Status.ID_IN_USE, nil, nil} - end - else - :bad_version -> {Status.BAD_VERSION, nil, nil} - :bad_password -> {Status.BAD_PASSWORD, nil, nil} -# {:enroll_subscriber, user} -> {Status.ENROLL_SUBSCRIBER, user} -# {:enroll_other, user} -> {Status.ENROLL_OTHER, user} - :id_in_use -> {Status.ID_IN_USE, nil, nil} - _ -> {Status.ACCOUNT_PROBLEM, nil, nil} - end - - response = - make_response_payload(result) - |> Fm0.make_response(request) - |> Packet.encode() - - case result do - {Status.SUCCESS, user, session_id} -> - Context.cancel_auth_timer(auth_timeout) - Logger.info("User #{user_id} logged on (Normal)") - {:ok, %Context{user: user, session_id: session_id, rs_version: version}, response} - - {Status.ENROLL_SUBSCRIBER, user, session_id} -> - Context.cancel_auth_timer(auth_timeout) - Logger.info("User #{user_id} logged on (Enroll Subscriber)") - {:ok, %Context{user: user, session_id: session_id, rs_version: version}, response} - - {Status.ENROLL_OTHER, user, session_id} -> - Context.cancel_auth_timer(auth_timeout) - Logger.info("User #{user_id} logged on (Enroll Other)") - {:ok, %Context{user: user, session_id: session_id, rs_version: version}, response} - - _ -> - {:error, context, response} - end + defp encode_data_collection_policy(policy) do + << + 1::1, + 0::3, + bool2int(policy.ad)::1, # 'A' + bool2int(policy.pwindow)::1, # 'W' + bool2int(policy.element)::1, # 'E' + bool2int(policy.template)::1, # 'T' + + # Function codes + bool2int(policy.exit)::1, # 'E' + bool2int(policy.undo)::1, # 'U' + bool2int(policy.path)::1, # 'P' + bool2int(policy.help)::1, # 'H' + bool2int(policy.jump)::1, # 'J' + bool2int(policy.back)::1, # 'B' + bool2int(policy.next)::1, # 'N' + bool2int(policy.commit)::1, # 'C' + 0::6, + bool2int(policy.action)::1, # 'A' + bool2int(policy.look)::1, # 'L' + 0::8 + >> end -end +end \ No newline at end of file