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
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
![]() |
|||||
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
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
![]() |
|||||
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 |
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.