mirror of
https://github.com/wavelog/wavelog
synced 2026-08-13 18:41:16 -04:00
323 lines
11 KiB
PHP
323 lines
11 KiB
PHP
<?php
|
|
|
|
/* Api_v2_model.php
|
|
*
|
|
* Manages the API v2 tokens (table `api_token`).
|
|
*
|
|
* v2 tokens are GitHub-style: "wl2_" prefix + cryptographically secure random
|
|
* part. Only a SHA-256 hash is stored in the database; the plaintext token is
|
|
* returned exactly once at creation time. Each token carries a set of granular
|
|
* scopes (see scope_registry()) instead of the legacy r/rw rights.
|
|
*
|
|
* The legacy v1 keys (table `api`, Api_model) are untouched by this model.
|
|
*/
|
|
|
|
class Api_v2_model extends CI_Model {
|
|
|
|
/** Prefix of every v2 token; also used to fast-reject legacy keys. */
|
|
const TOKEN_PREFIX = 'wl2_';
|
|
|
|
/**
|
|
* Central scope registry: scope id => human-readable label.
|
|
*
|
|
* Derived automatically from the resource classes in
|
|
* application/libraries/api_v2/ rather than hand-maintained: each resource
|
|
* declares its base scope ($scope) plus per-suffix labels (scope_labels()),
|
|
* and Api_v2_resource::scope_definitions() expands those into concrete
|
|
* "<resource>:<read|write|delete>" ids based on the verbs the resource
|
|
* actually implements. This keeps the UI checkboxes and token validation in
|
|
* lock-step with what the dispatcher enforces — adding a resource (or a verb)
|
|
* needs no change here. Result is memoized for the request.
|
|
*/
|
|
public static function scope_registry() {
|
|
static $registry = null;
|
|
if ($registry === null) {
|
|
$registry = self::collect_scopes();
|
|
}
|
|
|
|
return $registry;
|
|
}
|
|
|
|
/**
|
|
* The subset of scope_registry() the current session may actually hand out.
|
|
*
|
|
* Not memoized: the session can change within a request (impersonation).
|
|
*/
|
|
public static function grantable_scope_registry() {
|
|
return self::collect_scopes(true);
|
|
}
|
|
|
|
/**
|
|
* Walk the resource classes and collect their scope definitions.
|
|
*
|
|
* @param bool $grantable Also require is_grantable(), i.e. filter down to
|
|
* what the current session may hand out. Availability
|
|
* is checked either way, which is what keeps the
|
|
* grantable set a subset of the full registry.
|
|
*/
|
|
private static function collect_scopes($grantable = false) {
|
|
$registry = [];
|
|
|
|
foreach (glob(APPPATH . 'libraries/api_v2/*_resource.php') as $file) {
|
|
require_once $file;
|
|
// Convention: file "<Resource>_resource.php" holds class
|
|
// "<Resource>_resource" (see Api_v2::load_resource()).
|
|
$class = basename($file, '.php');
|
|
// Skip the abstract base and anything that is not a real resource.
|
|
if (!class_exists($class) || !is_subclass_of($class, 'Api_v2_resource')) {
|
|
continue;
|
|
}
|
|
if (!$class::is_available()) {
|
|
continue;
|
|
}
|
|
if ($grantable && !$class::is_grantable()) {
|
|
continue;
|
|
}
|
|
$registry += $class::scope_definitions();
|
|
}
|
|
|
|
return $registry;
|
|
}
|
|
|
|
/**
|
|
* Scope presets offered as one-click buttons in the create-token dialog.
|
|
*
|
|
* A preset is nothing but a named selection of scopes: picking one only
|
|
* ticks the matching checkboxes, the form still submits the individual
|
|
* scopes and the controller still validates them. Presets exist so users do
|
|
* not have to know which scopes a well-known third party tool needs.
|
|
*
|
|
* Two of them are derived from the registry ("read everything" / "full
|
|
* access") so they keep covering new resources automatically; the tool
|
|
* specific ones are hand-maintained and silently drop scopes that do not
|
|
* exist (yet), which keeps this list safe across versions.
|
|
*
|
|
* @return array<string,array{name:string,description:string,icon:string,class:string,scopes:string[]}>
|
|
*/
|
|
public static function preset_registry() {
|
|
$all = array_keys(self::grantable_scope_registry());
|
|
$read_only = array_values(array_filter($all, function ($scope) {
|
|
return substr($scope, -5) === ':read';
|
|
}));
|
|
|
|
$presets = [
|
|
'waveloggate' => [
|
|
'name' => 'WaveLogGate' . __(' (min. 2.1.0)'),
|
|
'description' => __("Radio control and QSO upload from WSJT-X, JTDX and friends. Requires at least version 2.1.0 of WaveLogGate."),
|
|
'icon' => 'fas fa-satellite-dish',
|
|
'class' => 'btn-primary',
|
|
'scopes' => ['station:read', 'qso:write', 'radio:write'],
|
|
],
|
|
'readonly' => [
|
|
'name' => __("Read-only"),
|
|
'description' => __("Read access to everything, no changes to your data."),
|
|
'icon' => 'fas fa-book-open',
|
|
'class' => 'btn-primary',
|
|
'scopes' => $read_only,
|
|
],
|
|
'statistics' => [
|
|
'name' => __("Statistics only"),
|
|
'description' => __("Statistics only, no changes to your data."),
|
|
'icon' => 'fas fa-chart-bar',
|
|
'class' => 'btn-primary',
|
|
'scopes' => ['statistic:read'],
|
|
],
|
|
];
|
|
|
|
// Drop scopes (and then empty presets) that this instance does not know.
|
|
foreach ($presets as $key => $preset) {
|
|
$presets[$key]['scopes'] = array_values(array_intersect($preset['scopes'], $all));
|
|
if (empty($presets[$key]['scopes'])) {
|
|
unset($presets[$key]);
|
|
}
|
|
}
|
|
|
|
return $presets;
|
|
}
|
|
|
|
/**
|
|
* Create a new token and return the plaintext token string (shown once),
|
|
* or false when the input is invalid.
|
|
*
|
|
* @param string $name Display name for the token (max 100 chars).
|
|
* @param array $scopes Scope ids; validated against scope_registry().
|
|
* @param string|null $expires_at Expiry as "Y-m-d H:i:s", or null for never.
|
|
* @param int|null $user_id Token owner; defaults to the session user.
|
|
* @param int|null $created_by Creator (clubstation); defaults to the owner.
|
|
* @return string|false
|
|
*/
|
|
function create_token($name, $scopes, $expires_at = null, $user_id = null, $created_by = null) {
|
|
$name = trim($name);
|
|
if ($name === '' || mb_strlen($name) > 100) {
|
|
return false;
|
|
}
|
|
|
|
// Only known scopes, at least one. Scopes are independent: a token can
|
|
// be write-only or delete-only on purpose (e.g. a logger that may push
|
|
// QSOs but must not read the logbook).
|
|
$valid = array_keys(self::scope_registry());
|
|
$scopes = array_values(array_unique(array_intersect((array) $scopes, $valid)));
|
|
if (empty($scopes)) {
|
|
return false;
|
|
}
|
|
|
|
$user_id = $user_id ?? $this->session->userdata('user_id');
|
|
$created_by = $created_by ?? $user_id;
|
|
|
|
// 4 char prefix + 40 hex chars = 44 chars, cryptographically secure.
|
|
$token = self::TOKEN_PREFIX . bin2hex(random_bytes(20));
|
|
|
|
$data = array(
|
|
'user_id' => $user_id,
|
|
'created_by' => $created_by,
|
|
'token_name' => xss_clean($name),
|
|
'token_hash' => hash('sha256', $token),
|
|
'scopes' => implode(',', $scopes),
|
|
'status' => 'active',
|
|
'expires_at' => $expires_at,
|
|
);
|
|
|
|
if ($this->db->insert('api_token', $data)) {
|
|
return $token;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Authenticate a plaintext token by its SHA-256 hash.
|
|
*
|
|
* Returns null for unknown or disabled tokens. Expiry is reported via the
|
|
* 'expired' flag so the caller can answer with a distinct error code.
|
|
*
|
|
* @param string $token Plaintext token as presented by the client.
|
|
* @return array|null { id, user_id, created_by, scopes => string[], expired => bool }
|
|
*/
|
|
function authenticate_token($token) {
|
|
$this->db->where('token_hash', hash('sha256', $token));
|
|
$query = $this->db->get('api_token');
|
|
|
|
if ($query->num_rows() !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$row = $query->row();
|
|
if ($row->status !== 'active') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $row->id,
|
|
'user_id' => (int) $row->user_id,
|
|
'created_by' => (int) $row->created_by,
|
|
'scopes' => explode(',', $row->scopes),
|
|
'expired' => ($row->expires_at !== null && strtotime($row->expires_at) < time()),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* All tokens of the current session user, for the management UI.
|
|
* Mirrors the clubstation visibility rules of Api_model::keys().
|
|
*/
|
|
function get_tokens_for_user() {
|
|
$binding = [];
|
|
$user_id = $this->session->userdata('user_id');
|
|
$clubstation = $this->session->userdata('clubstation');
|
|
$impersonate = $this->session->userdata('impersonate');
|
|
|
|
if ($clubstation == 1 && $impersonate == 1) {
|
|
$sql = "SELECT api_token.*, users.user_callsign
|
|
FROM api_token
|
|
JOIN users ON api_token.created_by = users.user_id
|
|
WHERE api_token.user_id = ?";
|
|
$binding[] = $user_id;
|
|
|
|
if (!clubaccess_check(9)) {
|
|
$sql .= " AND api_token.created_by = ?";
|
|
$binding[] = $this->session->userdata('source_uid');
|
|
}
|
|
} else {
|
|
$sql = "SELECT * FROM api_token WHERE user_id = ?";
|
|
$binding[] = $user_id;
|
|
}
|
|
|
|
$sql .= " ORDER BY created_at DESC";
|
|
|
|
return $this->db->query($sql, $binding);
|
|
}
|
|
|
|
/**
|
|
* Delete a token owned by the current session user.
|
|
* Clubstation: non-officers may only delete tokens they created themselves.
|
|
*/
|
|
function revoke_token($id) {
|
|
$this->db->where('id', (int) $id);
|
|
$this->db->where('user_id', $this->session->userdata('user_id'));
|
|
|
|
if ($this->session->userdata('clubstation') == 1
|
|
&& $this->session->userdata('impersonate') == 1
|
|
&& !clubaccess_check(9)) {
|
|
$this->db->where('created_by', $this->session->userdata('source_uid'));
|
|
}
|
|
|
|
$this->db->delete('api_token');
|
|
}
|
|
|
|
/**
|
|
* Delete the tokens a member issued for a clubstation.
|
|
*
|
|
* @param int $club_id The clubstation.
|
|
* @param int|null $user_id One member, or null for every member of the club.
|
|
*/
|
|
function revoke_club_tokens($club_id, $user_id = null) {
|
|
if (!is_numeric($club_id) || (int) $club_id < 1) {
|
|
return;
|
|
}
|
|
|
|
$this->db->where('user_id', (int) $club_id);
|
|
if ($user_id !== null) {
|
|
$this->db->where('created_by', (int) $user_id);
|
|
} else {
|
|
// A clubstation can hold tokens of its own (club_direct login);
|
|
// those belong to the account, not to a membership.
|
|
$this->db->where('created_by !=', (int) $club_id);
|
|
}
|
|
$this->db->delete('api_token');
|
|
}
|
|
|
|
/**
|
|
* Metadata of a single token, for the whoami endpoint (Token_resource).
|
|
* Never exposes the hash; the plaintext token is not stored at all.
|
|
*
|
|
* @param int $id Token primary key.
|
|
* @return array|null { token_name, expires_at, created_at, last_used, status }
|
|
*/
|
|
function get_token_meta($id) {
|
|
$this->db->where('id', (int) $id);
|
|
$query = $this->db->get('api_token');
|
|
if ($query->num_rows() !== 1) {
|
|
return null;
|
|
}
|
|
$row = $query->row();
|
|
return [
|
|
'token_name' => $row->token_name,
|
|
'expires_at' => $row->expires_at,
|
|
'created_at' => $row->created_at,
|
|
'last_used' => $row->last_used,
|
|
'status' => $row->status,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Track token usage: token last_used plus the owner's last_seen.
|
|
*
|
|
* @param int $token_id Token primary key.
|
|
* @param int $user_id Token owner (known to the caller from authenticate_token()).
|
|
*/
|
|
function update_last_used($token_id, $user_id) {
|
|
$this->db->set('last_used', 'NOW()', FALSE);
|
|
$this->db->where('id', (int) $token_id);
|
|
$this->db->update('api_token');
|
|
|
|
$this->user_model->set_last_seen($user_id);
|
|
}
|
|
}
|