Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CU Auth package #67

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7d55d3d
Initial concept for CUAuth (mod_shib) integration
woodseowl Oct 13, 2023
5b76076
Add non-prod login protection
woodseowl Jan 19, 2024
42698e0
Adjust middleware and tests
woodseowl Apr 25, 2024
b865c93
Refactor for alternate middleware
woodseowl Apr 25, 2024
19b8dcb
Rename configs for clarity
woodseowl Apr 25, 2024
574ff68
Add AppTesters as a separate middleware; Docs
woodseowl Apr 25, 2024
bcb542e
Merge branch 'refs/heads/main' into cu-auth
woodseowl Apr 25, 2024
11afed4
Linting fixes
woodseowl Jul 25, 2024
3975ee4
Fix readme language
woodseowl Aug 12, 2024
eb6fcc0
Merge branch 'refs/heads/updates' into cu-auth-install
woodseowl Aug 14, 2024
0ab7c75
Passing tests
woodseowl Aug 14, 2024
7f4fdd3
Linting
woodseowl Aug 14, 2024
a3d1324
Allow auth without user, fix local override
woodseowl Aug 14, 2024
e29914b
Fix AppTesters; config clean up
woodseowl Aug 15, 2024
f9cfa52
Readme improvements
woodseowl Aug 15, 2024
d7ae145
Tweak AppTesters
woodseowl Aug 15, 2024
ca5dfb5
Complete test coverage
woodseowl Aug 15, 2024
0ca73ee
Use aborts
woodseowl Aug 15, 2024
d84a7c4
Handle empty app_testers
woodseowl Aug 15, 2024
1121870
Initial ShibIdentity data object
woodseowl Aug 16, 2024
6e3ef07
Linting
woodseowl Aug 16, 2024
3ffe68d
Structure shib mail + name a bit
woodseowl Aug 16, 2024
e810e21
Documentation
woodseowl Aug 16, 2024
10bfe99
Move authentication to a controller
woodseowl Oct 30, 2024
06b9f98
Linting
woodseowl Oct 30, 2024
0a69859
request server default fix
woodseowl Oct 31, 2024
325c81f
Merge branch 'main' into cu-auth-install
woodseowl Oct 31, 2024
40a93fd
README updates
woodseowl Nov 12, 2024
20cb727
Refactor for direct shib URLs
woodseowl Nov 13, 2024
7185e38
Test coverage
woodseowl Nov 13, 2024
ede88df
Linting
woodseowl Nov 13, 2024
9e5454c
Add weill conditions
woodseowl Nov 14, 2024
017cd1b
Refactor user lookup
woodseowl Nov 14, 2024
cc9d6db
Complete session logout
woodseowl Nov 25, 2024
c98c7f2
Fix session context for authcontroller
woodseowl Nov 25, 2024
8d362b8
Fix logout for local testing
woodseowl Nov 27, 2024
1792671
Fix AppTesters case
woodseowl Nov 27, 2024
b95670f
Doc updates
woodseowl Nov 27, 2024
52b7b48
Shib/apache docs
woodseowl Dec 2, 2024
65bd7af
ShibIdentity improvements
woodseowl Dec 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A Cornell University CIT Custom Development starter kit and library for Laravel.

## Usage

The Starter Kit can be used as a starter kit for a new site or as a library for an existing site.
The Starter Kit can be used [as a starter kit for a new site](#as-a-starter-kit-for-a-new-site) or [as a library for an existing site](#as-a-library-for-an-existing-site).

### As a Starter Kit for a New Site

Expand Down Expand Up @@ -84,7 +84,7 @@ For an existing Laravel site, this package can be composer-required to provide t
The libraries included in the Starter Kit are documented in their respective README files:

- [Contact/PhoneNumber](src/Contact/README.md): A library for parsing and formatting a phone number.

- [CUAuth](src/CUAuth/README.md): A middleware for authorizing Laravel users, mostly for Apache mod_shib authentication.

## Deploying a site
Once a Media3 site has been created, you have confirmed you can reach the default site via a web browser, and you have access to the site login by command line, the code can be deployed.
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"extra": {
"laravel": {
"providers": [
"CornellCustomDev\\LaravelStarterKit\\StarterKitServiceProvider"
"CornellCustomDev\\LaravelStarterKit\\StarterKitServiceProvider",
"CornellCustomDev\\LaravelStarterKit\\CUAuth\\CUAuthServiceProvider"
]
}
},
Expand Down
52 changes: 52 additions & 0 deletions config/cu-auth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

return [
/*
|--------------------------------------------------------------------------
| ApacheShib Configuration
|--------------------------------------------------------------------------
|
| ApacheShib retrieves user data from server variables populated by the
| Apache shibboleth module (mod_shib).
|
| The default user variable is "REMOTE_USER", but this may differ depending
| on how mod_shib is configured.
|
| For local development without shibboleth, you can add
| REMOTE_USER=<netid> to your project .env file to log in as that user.
|
| To require a local user be logged in based on the remote user, set
| REQUIRE_LOCAL_USER to true.
|
*/
'apache_shib_user_variable' => env('APACHE_SHIB_USER_VARIABLE', 'REMOTE_USER'),
'remote_user_override' => env('REMOTE_USER'),

'require_local_user' => env('REQUIRE_LOCAL_USER', false),

'shibboleth_login_url' => env('SHIBBOLETH_LOGIN_URL', '/Shibboleth.sso/Login'),
'shibboleth_logout_url' => env('SHIBBOLETH_LOGOUT_URL', '/Shibboleth.sso/Logout'),

/*
|--------------------------------------------------------------------------
| AppTesters Configuration
|--------------------------------------------------------------------------
|
| Comma-separated list of users to allow in development environments.
| APP_TESTERS_FIELD is the field on the user model to compare against.
|
*/
'app_testers' => env('APP_TESTERS', ''),
'app_testers_field' => env('APP_TESTERS_FIELD', 'netid'),

/*
|--------------------------------------------------------------------------
| Allow Local Login
|--------------------------------------------------------------------------
|
| Allow Laravel password-based login? Typically, this would only be used
| for local or automated testing.
|
*/
'allow_local_login' => boolval(env('ALLOW_LOCAL_LOGIN', false)),
];
2 changes: 1 addition & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
bootstrap="vendor/autoload.php"
colors="true"
testdox="true"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
cacheDirectory=".phpunit.cache"
>
<source>
Expand Down
29 changes: 29 additions & 0 deletions src/CUAuth/CUAuthServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace CornellCustomDev\LaravelStarterKit\CUAuth;

use CornellCustomDev\LaravelStarterKit\StarterKitServiceProvider;
use Illuminate\Support\ServiceProvider;

class CUAuthServiceProvider extends ServiceProvider
{
const INSTALL_CONFIG_TAG = 'cu-auth-config';

public function register(): void
{
$this->mergeConfigFrom(
path: __DIR__.'/../../config/cu-auth.php',
key: 'cu-auth',
);
}

public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../../config/cu-auth.php' => config_path('cu-auth.php'),
], StarterKitServiceProvider::PACKAGE_NAME.':'.self::INSTALL_CONFIG_TAG);
}
$this->loadRoutesFrom(__DIR__.'/routes.php');
}
}
110 changes: 110 additions & 0 deletions src/CUAuth/DataObjects/ShibIdentity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace CornellCustomDev\LaravelStarterKit\CUAuth\DataObjects;

use Illuminate\Http\Request;

class ShibIdentity
{
// Shibboleth fields generally available from either cit or weill IdPs.
public const SHIB_FIELDS = [
'Shib_Application_ID', // <vhost|applicationId>
'Shib_Authentication_Instant', // YYYY-MM-DDT00:00:00.000Z
'Shib_Identity_Provider', // https://shibidp.cit.cornell.edu/idp/shibboleth|https://login.weill.cornell.edu/idp
'Shib_Session_Expires', // timestamp
'Shib_Session_Inactivity', // timestamp
'displayName', // John Doe
'eduPersonAffiliations', // employee;member;staff
'eduPersonPrincipalName', // [email protected]|[email protected]
'eduPersonScopedAffiliation', // employee@[med.]cornell.edu;member@[med.]cornell.edu;[email protected]
'givenName', // John
'mail', // alias email
'sn', // Doe
'uid', // netid|cwid
];

public function __construct(
public readonly string $idp,
public readonly string $uid,
public readonly string $displayName = '',
public readonly string $email = '',
public readonly array $serverVars = [],
) {}

/**
* Shibboleth server variables will be retrieved from the request if not provided.
*/
public static function fromServerVars(?array $serverVars = null): self
{
if (empty($serverVars)) {
$serverVars = app('request')->server();
}

return new ShibIdentity(
idp: $serverVars['Shib_Identity_Provider'] ?? '',
uid: $serverVars['uid'] ?? '',
displayName: $serverVars['displayName']
?? $serverVars['cn']
?? trim(($serverVars['givenName'] ?? '').' '.($serverVars['sn'] ?? '')),
email: $serverVars['eduPersonPrincipalName']
?? $serverVars['mail'] ?? '',
serverVars: $serverVars,
);
}

public static function getRemoteUser(?Request $request = null): ?string
{
if (empty($request)) {
$request = app('request');
}

// If this is a local development environment, allow the local override.
$remote_user_override = self::getRemoteUserOverride();

// Apache mod_shib populates the remote user variable if someone is logged in.
return $request->server(config('cu-auth.apache_shib_user_variable')) ?: $remote_user_override;
}

public static function getRemoteUserOverride(): ?string
{
// If this is a local development environment, allow the local override.
return app()->isLocal() ? config('cu-auth.remote_user_override') : null;
}

public function isCornellIdP(): bool
{
return str_contains($this->idp, 'cit.cornell.edu');
}

public function isWeillIdP(): bool
{
return str_contains($this->idp, 'weill.cornell.edu');
}

/**
* Provides a uid that is unique across Cornell IdPs.
*/
public function uniqueUid(): string
{
return match (true) {
$this->isCornellIdP() => $this->uid,
$this->isWeillIdP() => $this->uid.'_w',
};
}

/**
* Returns the primary email ([email protected]|[email protected]) if available, otherwise the alias email.
*/
public function email(): string
{
return $this->email;
}

/**
* Returns the display name if available, otherwise the common name, fallback is "givenName sn".
*/
public function name(): string
{
return $this->displayName;
}
}
15 changes: 15 additions & 0 deletions src/CUAuth/Events/CUAuthenticated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace CornellCustomDev\LaravelStarterKit\CUAuth\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class CUAuthenticated
{
use Dispatchable, SerializesModels;

public function __construct(
public readonly string $remoteUser,
) {}
}
41 changes: 41 additions & 0 deletions src/CUAuth/Http/Controllers/AuthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace CornellCustomDev\LaravelStarterKit\CUAuth\Http\Controllers;

use CornellCustomDev\LaravelStarterKit\CUAuth\DataObjects\ShibIdentity;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;

class AuthController extends BaseController
{
public function shibbolethLogin(Request $request)
{
$redirectUri = $request->query('redirect_uri', '/');

if (ShibIdentity::getRemoteUser($request)) {
// Already logged in so redirect to the originally intended URL
return redirect()->to($redirectUri);
}

// Use the Shibboleth login URL
return redirect(config('cu-auth.shibboleth_login_url').'?target='.urlencode($redirectUri));
}

public function shibbolethLogout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();

$returnUrl = $request->query('return', '/');

if (ShibIdentity::getRemoteUserOverride()) {
// If using locally configured remote user, there is no Shibboleth logout
return redirect()->to($returnUrl);
}

// Use the Shibboleth logout URL
return redirect(config('cu-auth.shibboleth_logout_url').'?return='.urlencode($returnUrl));
}
}
33 changes: 33 additions & 0 deletions src/CUAuth/Listeners/AuthorizeUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace CornellCustomDev\LaravelStarterKit\CUAuth\Listeners;

use CornellCustomDev\LaravelStarterKit\CUAuth\DataObjects\ShibIdentity;
use CornellCustomDev\LaravelStarterKit\CUAuth\Events\CUAuthenticated;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class AuthorizeUser
{
public function handle(CUAuthenticated $event, ?array $serverVars = null): void
{
$shibboleth = ShibIdentity::fromServerVars($serverVars);

// Look for a matching user.
$userModel = config('auth.providers.users.model');
$user = $userModel::firstWhere('email', $shibboleth->email());

if (empty($user)) {
// User does not exist, so create them.
$user = new $userModel;
$user->name = $shibboleth->name();
$user->email = $shibboleth->email();
$user->password = Str::random(32);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to use eduPersonPrincipalName (netid email) here instead of mail attribute which is alias email. This can be changed later.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently I was already in agreement with you! Looking at the ShibIdentity implementation, it uses eduPersonPrincipalName as the value for email if it is available, and it uses mail as a fallback. (See ShibIdentity::fromServerVars() and the phpdoc for ShibIdentity::email())

So, basically, yes, and already done!

$user->save();
Log::info("AuthorizeUser: Created user $user->email with ID $user->id.");
}

auth()->login($user);
Log::info("AuthorizeUser: Logged in user $user->email.");
}
}
50 changes: 50 additions & 0 deletions src/CUAuth/Middleware/ApacheShib.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace CornellCustomDev\LaravelStarterKit\CUAuth\Middleware;

use Closure;
use CornellCustomDev\LaravelStarterKit\CUAuth\DataObjects\ShibIdentity;
use CornellCustomDev\LaravelStarterKit\CUAuth\Events\CUAuthenticated;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ApacheShib
{
public function handle(Request $request, Closure $next): Response
{
// If local login is allowed and someone is authenticated, let them through.
if (config('cu-auth.allow_local_login') && auth()->check()) {
return $next($request);
}

// Shibboleth login route is allowed to pass through.
if ($request->path() == route('cu-auth.shibboleth-login')) {
return $next($request);
}

// remoteUser will be set for authenticated users.
$remoteUser = ShibIdentity::getRemoteUser($request);

// Unauthenticated get redirected to Shibboleth login.
if (empty($remoteUser)) {
return redirect()->route('cu-auth.shibboleth-login', [
'redirect_uri' => $request->fullUrl(),
]);
}

// If requiring a local user, attempt to log in the user.
if (config('cu-auth.require_local_user') && ! auth()->check()) {
event(new CUAuthenticated($remoteUser));

// If the authenticated user is still not logged in, return a 403.
if (! auth()->check()) {
if (app()->runningInConsole()) {
return response('Forbidden', Response::HTTP_FORBIDDEN);
}
abort(403);
}
}

return $next($request);
}
}
Loading
Loading