DONE: disable login and implement sso
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API\v1\Voter;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use App\Support\ApiTokenLifetime;
|
||||
use App\Support\JwtVerifier;
|
||||
use App\Voter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$token = $request->query('token');
|
||||
|
||||
if (! $token) {
|
||||
return $this->redirectToLogin('Token SSO tidak sah.', 'token_invalid');
|
||||
}
|
||||
|
||||
$secret = config('services.mykopkb.sso_secret');
|
||||
if (! $secret) {
|
||||
return $this->redirectToLogin(
|
||||
app()->environment('local', 'development')
|
||||
? 'MYKOPKB_SSO_SECRET belum dikonfigurasi dalam .env.'
|
||||
: 'Token SSO tamat tempoh atau tidak sah.',
|
||||
'token_invalid'
|
||||
);
|
||||
}
|
||||
|
||||
$payload = JwtVerifier::verify($token, $secret, [
|
||||
'issuer' => config('services.mykopkb.sso_issuer', 'mykopkb'),
|
||||
'audience' => config('services.mykopkb.sso_audience', 'e-vote'),
|
||||
]);
|
||||
|
||||
if (! $payload) {
|
||||
return $this->redirectToLogin('Token SSO tamat tempoh atau tidak sah.', 'token_invalid');
|
||||
}
|
||||
|
||||
$jti = $payload['jti'] ?? null;
|
||||
if (! $jti || Cache::has("sso_jti:$jti")) {
|
||||
return $this->redirectToLogin('Token SSO telah digunakan.', 'token_used');
|
||||
}
|
||||
|
||||
$icNumber = preg_replace('/[\s-]+/', '', (string) ($payload['ic_number'] ?? ''));
|
||||
$memberNumber = trim((string) ($payload['member_number'] ?? ''));
|
||||
|
||||
if ($icNumber === '' || $memberNumber === '') {
|
||||
return $this->redirectToLogin('Token SSO tidak sah.', 'token_invalid');
|
||||
}
|
||||
|
||||
$voter = Voter::query()
|
||||
->where('no_kp', $icNumber)
|
||||
->where('no_anggota', $memberNumber)
|
||||
->where('election_id', Util::getCurrentElection())
|
||||
->first();
|
||||
|
||||
if (! $voter) {
|
||||
activity()
|
||||
->withProperties([
|
||||
'ic_number' => $icNumber,
|
||||
'member_number' => $memberNumber,
|
||||
'election_id' => Util::getCurrentElection(),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
])
|
||||
->log('voter sso login rejected: not in current election');
|
||||
|
||||
return $this->redirectToLogin(
|
||||
'Akaun pengundi tidak dijumpai untuk pilihan raya semasa. Sila daftar di kaunter IT atau log masuk secara manual.',
|
||||
'voter_not_found'
|
||||
);
|
||||
}
|
||||
|
||||
Cache::put("sso_jti:$jti", true, now()->addMinutes(5));
|
||||
|
||||
ApiTokenLifetime::revokeActiveTokens($voter);
|
||||
|
||||
$passportToken = $voter->createToken('SSO Login', ['vote'])->accessToken;
|
||||
$meta = ApiTokenLifetime::loginMeta('voter');
|
||||
|
||||
activity()
|
||||
->performedOn($voter)
|
||||
->withProperties([
|
||||
'election_id' => Util::getCurrentElection(),
|
||||
'voter_id' => $voter->id,
|
||||
'voter_name' => $voter->name,
|
||||
'no_kp' => $voter->no_kp,
|
||||
'no_anggota' => $voter->no_anggota,
|
||||
'sso_sub' => $payload['sub'] ?? null,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
])
|
||||
->log("voter sso login: {$voter->name}");
|
||||
|
||||
$query = http_build_query([
|
||||
'sso_token' => $passportToken,
|
||||
'token_expires_at' => $meta['token_expires_at'],
|
||||
]);
|
||||
|
||||
return redirect('/login?'.$query);
|
||||
}
|
||||
|
||||
private function redirectToLogin(string $message, string $code = 'sso_error')
|
||||
{
|
||||
return redirect('/login?'.http_build_query([
|
||||
'sso_error' => $message,
|
||||
'sso_error_code' => $code,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class JwtVerifier
|
||||
{
|
||||
/**
|
||||
* Verify an HS256 JWT and return the payload, or null if invalid.
|
||||
*
|
||||
* @param array{issuer?: string, audience?: string, leeway?: int} $options
|
||||
*/
|
||||
public static function verify(string $token, string $secret, array $options = []): ?array
|
||||
{
|
||||
$result = self::diagnose($token, $secret, $options);
|
||||
|
||||
return $result['valid'] ? $result['payload'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{issuer?: string, audience?: string, leeway?: int} $options
|
||||
* @return array{
|
||||
* valid: bool,
|
||||
* reason: string|null,
|
||||
* payload: array|null,
|
||||
* claims: array<string, mixed>|null
|
||||
* }
|
||||
*/
|
||||
public static function diagnose(string $token, string $secret, array $options = []): array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return self::failure('malformed_token');
|
||||
}
|
||||
|
||||
[$headerB64, $payloadB64, $signatureB64] = $parts;
|
||||
|
||||
$expected = self::base64UrlEncode(
|
||||
hash_hmac('sha256', "$headerB64.$payloadB64", $secret, true)
|
||||
);
|
||||
|
||||
if (! hash_equals($expected, $signatureB64)) {
|
||||
return self::failure('invalid_signature', null, self::decodePayload($payloadB64));
|
||||
}
|
||||
|
||||
$header = json_decode(self::base64UrlDecode($headerB64), true);
|
||||
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
|
||||
|
||||
if (! is_array($header) || ! is_array($payload)) {
|
||||
return self::failure('invalid_json');
|
||||
}
|
||||
|
||||
if (($header['alg'] ?? '') !== 'HS256') {
|
||||
return self::failure('unsupported_algorithm', $payload);
|
||||
}
|
||||
|
||||
$issuer = $options['issuer'] ?? null;
|
||||
if ($issuer !== null && ($payload['iss'] ?? '') !== $issuer) {
|
||||
return self::failure('issuer_mismatch', $payload);
|
||||
}
|
||||
|
||||
$audience = $options['audience'] ?? null;
|
||||
if ($audience !== null && ($payload['aud'] ?? '') !== $audience) {
|
||||
return self::failure('audience_mismatch', $payload);
|
||||
}
|
||||
|
||||
$leeway = (int) ($options['leeway'] ?? 0);
|
||||
$now = time();
|
||||
|
||||
if (($payload['exp'] ?? 0) < ($now - $leeway)) {
|
||||
return self::failure('token_expired', $payload);
|
||||
}
|
||||
|
||||
if (($payload['nbf'] ?? 0) > ($now + $leeway)) {
|
||||
return self::failure('token_not_yet_valid', $payload);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'reason' => null,
|
||||
'payload' => $payload,
|
||||
'claims' => self::summarizeClaims($payload),
|
||||
];
|
||||
}
|
||||
|
||||
public static function isLaravelEncryptedSecret(?string $secret): bool
|
||||
{
|
||||
if (! is_string($secret) || $secret === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($secret, true);
|
||||
if ($decoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = json_decode($decoded, true);
|
||||
|
||||
return is_array($json)
|
||||
&& array_key_exists('iv', $json)
|
||||
&& array_key_exists('value', $json)
|
||||
&& array_key_exists('mac', $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected static function decodePayload(string $payloadB64): ?array
|
||||
{
|
||||
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
|
||||
|
||||
return is_array($payload) ? $payload : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $payload
|
||||
* @return array{valid: false, reason: string, payload: null, claims: array<string, mixed>|null}
|
||||
*/
|
||||
protected static function failure(string $reason, ?array $payload = null, ?array $claims = null): array
|
||||
{
|
||||
return [
|
||||
'valid' => false,
|
||||
'reason' => $reason,
|
||||
'payload' => null,
|
||||
'claims' => $claims ?? ($payload ? self::summarizeClaims($payload) : null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected static function summarizeClaims(array $payload): array
|
||||
{
|
||||
return [
|
||||
'iss' => $payload['iss'] ?? null,
|
||||
'aud' => $payload['aud'] ?? null,
|
||||
'exp' => $payload['exp'] ?? null,
|
||||
'nbf' => $payload['nbf'] ?? null,
|
||||
'ic_number' => $payload['ic_number'] ?? null,
|
||||
'member_number' => $payload['member_number'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function base64UrlDecode(string $data): string
|
||||
{
|
||||
$remainder = strlen($data) % 4;
|
||||
if ($remainder) {
|
||||
$data .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
|
||||
}
|
||||
|
||||
protected static function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user