validateOauthCreateOrUpdateUserAndGroups()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 9
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 19
rs 9.9666
1
<?php
2
3
namespace Metaclassing\EnterpriseAuth\Controllers;
4
5
use Illuminate\Routing\Controller;
6
7
class AuthController extends Controller
8
{
9
    protected $azureActiveDirectory;
10
11
    public function __construct()
12
    {
13
        $tenant = config('enterpriseauth.credentials.tenant');
14
        $this->azureActiveDirectory = new \Metaclassing\EnterpriseAuth\AzureActiveDirectory($tenant);
15
    }
16
17
    // This is called after a web auth gets an access token, or api auth sends an access token
18
    public function validateOauthCreateOrUpdateUserAndGroups($accessToken)
19
    {
20
        $userData = $this->getMicrosoftGraphSelf($accessToken);
21
        $userData = $this->scrubMicrosoftGraphUserData($userData);
22
23
        // This is a laravel \App\User
24
        $user = $this->findOrCreateUser($userData);
25
26
        // Try to update the group/role membership for this user
27
        $this->updateGroups($user);
28
29
        // Cache the users oauth accss token mapped to their user object for stuff and things
30
        $key = '/oauth/tokens/'.$accessToken;
31
        $remaining = $this->getTokenMinutesRemaining($accessToken);
32
        \Illuminate\Support\Facades\Log::debug('oauth token cached for '.$remaining.' minutes');
33
        // Cache the token until it expires
34
        \Cache::put($key, $user, $remaining);
35
36
        return $user;
37
    }
38
39
    public function getMicrosoftGraphSelf($accessToken)
40
    {
41
        $graph = new \Microsoft\Graph\Graph();
42
        $graph->setAccessToken($accessToken);
43
        $user = $graph->createRequest('GET', '/me')
44
                      ->setReturnType(\Microsoft\Graph\Model\User::class)
45
                      ->execute();
46
47
        return $user->jsonSerialize();
0 ignored issues
show
Bug introduced by
The method jsonSerialize() does not exist on Microsoft\Graph\Http\GraphResponse. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

47
        return $user->/** @scrutinizer ignore-call */ jsonSerialize();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method jsonSerialize() does not exist on Psr\Http\Message\StreamInterface. It seems like you code against a sub-type of said class. However, the method does not exist in GuzzleHttp\Psr7\BufferStream or GuzzleHttp\Psr7\PumpStream or GuzzleHttp\Psr7\AppendStream or GuzzleHttp\Psr7\Stream or GuzzleHttp\Psr7\FnStream. Are you sure you never get one of those? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

47
        return $user->/** @scrutinizer ignore-call */ jsonSerialize();
Loading history...
48
    }
49
50
    public function scrubMicrosoftGraphUserData($userData)
51
    {
52
        // Fix any stupid crap with missing or null fields
53
        if (! isset($userData['mail']) || ! $userData['mail']) {
54
            \Illuminate\Support\Facades\Log::debug('graph api did not contain mail field, using userPrincipalName instead '.json_encode($userData));
55
            $userData['mail'] = $userData['userPrincipalName'];
56
        }
57
58
        return $userData;
59
    }
60
61
    protected function findOrCreateUser($userData)
62
    {
63
        // Configurable \App\User type and ID field name
64
        $userType = config('enterpriseauth.user_class');
65
        $userIdField = config('enterpriseauth.user_id_field');
66
        // Try to find an existing user
67
        $user = $userType::where($userIdField, $userData['id'])->first();
68
        // If we dont have an existing user
69
        if (! $user) {
70
            // Go create a new one with this data
71
            $user = $this->createUserFromAzureData($userData);
72
        }
73
74
        return $user;
75
    }
76
77
    // This takes the azure userdata and makes a new user out of it
78
    public function createUserFromAzureData($userData)
79
    {
80
        // Config options for user type/id/field map
81
        $userType = config('enterpriseauth.user_class');
82
        $userFieldMap = config('enterpriseauth.user_map');
83
        $idField = config('enterpriseauth.user_id_field');
84
85
        // Should build new \App\User
86
        $user = new $userType();
87
        $user->$idField = $userData['id'];
88
        // Go through any other fields the config wants us to map
89
        foreach ($userFieldMap as $azureField => $userField) {
90
            if (isset($userData[$azureField])) {
91
                $user->$userField = $userData[$azureField];
92
            } else {
93
                \Illuminate\Support\Facades\Log::info('createUserFromAzureData did not contain configured field '.$azureField.' in '.json_encode($userData));
94
            }
95
        }
96
        // Save our newly minted user
97
        $user->save();
98
99
        return $user;
100
    }
101
102
    public function certAuth()
103
    {
104
        // get the cert from the webserver and load it into an x509 phpseclib object
105
        $cert = $this->loadClientCertFromWebserver();
106
        // extract the UPN from the client cert
107
        $upn = $this->getUserPrincipalNameFromClientCert($cert);
108
        // get the user if it exists
109
        $user_class = config('enterpriseauth.user_class');
110
111
        // TODO: rewrite this so that if the user doesnt exist we create them and get their groups from AAD
112
        $user = $user_class::where('userPrincipalName', $upn)->first();
113
        if (! $user) {
114
            throw new \Exception('No user found with user principal name '.$upn);
115
        }
116
117
        return $user;
118
    }
119
120
    public function loadClientCertFromWebserver()
121
    {
122
        // Make sure we got a client certificate from the web server
123
        if (! isset($_SERVER['SSL_CLIENT_CERT']) || ! $_SERVER['SSL_CLIENT_CERT']) {
124
            throw new \Exception('TLS client certificate missing');
125
        }
126
        // try to parse the certificate we got
127
        $x509 = new \phpseclib\File\X509();
128
        // NGINX screws up the cert by putting a bunch of tab characters into it so we need to clean those out
129
        $asciicert = str_replace("\t", '', $_SERVER['SSL_CLIENT_CERT']);
130
        $x509->loadX509($asciicert);
131
132
        return $x509;
133
    }
134
135
    public function getUserPrincipalNameFromClientCert($x509)
136
    {
137
        $names = $x509->getExtension('id-ce-subjectAltName');
138
        if (! $names) {
139
            throw new \Exception('TLS client cert missing subject alternative names');
140
        }
141
        // Search subject alt names for user principal name
142
        $upn = '';
143
        foreach ($names as $name) {
144
            foreach ($name as $key => $value) {
145
                if ($key == 'otherName') {
146
                    if (isset($value['type-id']) && $value['type-id'] == '1.3.6.1.4.1.311.20.2.3') {
147
                        $upn = $value['value']['utf8String'];
148
                    }
149
                }
150
            }
151
        }
152
        if (! $upn) {
153
            throw new \Exception('Could not find user principal name in TLS client cert');
154
        }
155
156
        return $upn;
157
    }
158
159
    public function updateGroups($user)
160
    {
161
        // See if we can get the users group membership data
162
        $groupData = $this->getMicrosoftGraphGroupMembership($user);
163
164
        // Process group data into a list of displayNames we use as roles
165
        $groups = [];
166
        foreach ($groupData as $info) {
167
            // Now there are NEW kinds of awful groups where groupTypes => [Unified] is BAD.
168
            // We only want to process groupTypes = [] (empty set evaluates to false)
169
            if (isset($info['groupTypes']) && $info['groupTypes'] == false) {
170
                $groups[] = $info['displayName'];
171
            } else {
172
                \Illuminate\Support\Facades\Log::debug('skipping grouptype named '.$info['displayName']);
173
            }
174
        }
175
        // make sure the array of groups is UNIQUE because stupid azuread names are not!
176
        $groups = array_unique($groups);
177
178
        // If we have user group information from this oauth attempt
179
        \Illuminate\Support\Facades\Log::debug('assigning user to '.count($groups).' groups as roles');
180
        if (count($groups)) {
181
            // remove the users existing database roles before assigning new ones
182
            \DB::table('assigned_roles')
183
               ->where('entity_id', $user->id)
184
               ->where('entity_type', get_class($user))
185
               ->delete();
186
            // TRY to add the user to each group they are assigned
187
            try {
188
                $user->assign($groups);
189
            } catch (\Exception $e) {
190
                \Illuminate\Support\Facades\Log::debug('unable to add user to groups '.implode(',', $groups).' because'.$e->getMessage());
191
            }
192
        }
193
    }
194
195
    public function getMicrosoftGraphGroupMembership($user)
196
    {
197
        // Get an access token for our application (not the users token)
198
        $accessToken = $this->azureActiveDirectory->getApplicationAccessToken(config('enterpriseauth.credentials.client_id'), config('enterpriseauth.credentials.client_secret'));
199
200
        // Use the app access token to get a given users group membership
201
        $graph = new \Microsoft\Graph\Graph();
202
        $graph->setAccessToken($accessToken);
203
        $path = '/users/'.$user->userPrincipalName.'/memberOf';
204
//      $groups = $graph->createRequest('GET', $path)
205
//      $graph->createCollectionRequest('GET', $path)->setReturnType(\Microsoft\Graph\Model\Group::class)->setPageSize(200)->execute();
206
        $groups = $graph->createCollectionRequest('GET', $path)
207
                        ->setReturnType(\Microsoft\Graph\Model\Group::class)
208
                        ->setPageSize(900)
209
                        ->execute();
210
        \Illuminate\Support\Facades\Log::debug('azure ad returned '.count($groups).' groups for user');
0 ignored issues
show
Bug introduced by
It seems like $groups can also be of type Microsoft\Graph\Http\GraphResponse and Psr\Http\Message\StreamInterface; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

210
        \Illuminate\Support\Facades\Log::debug('azure ad returned '.count(/** @scrutinizer ignore-type */ $groups).' groups for user');
Loading history...
211
212
        // Convert the microsoft graph group objects into data that is useful
213
        $groupData = [];
214
        foreach ($groups as $group) {
215
            $groupData[] = $group->jsonSerialize();
216
        }
217
218
        return $groupData;
219
    }
220
221
    // Try to unpack a jwt and get us the 3 chunks as assoc arrays so we can perform token identification
222
    public function unpackJwt($jwt)
223
    {
224
        list($headb64, $bodyb64, $cryptob64) = explode('.', $jwt);
225
        $token = [
226
            'header'    => json_decode(\Firebase\JWT\JWT::urlsafeB64Decode($headb64), true),
227
            'payload'   => json_decode(\Firebase\JWT\JWT::urlsafeB64Decode($bodyb64), true),
228
            'signature' => $cryptob64,
229
        ];
230
231
        return $token;
232
    }
233
234
    // calculate the delta between $jwt['exp'] and time() / 60 for minutes remaining
235
    protected function getTokenMinutesRemaining($accessToken)
236
    {
237
        $tokenData = $this->unpackJwt($accessToken);
238
        $now = time();
239
        $expires = $tokenData['payload']['exp'];
240
        $remainingSecs = $expires - $now;
241
        // round up to the nearest minute
242
        $remainingMins = ceil($remainingSecs / 60);
243
244
        return $remainingMins;
245
    }
246
}
247