diff --git a/README.md b/README.md index ac70a6a7c..dcc8bd114 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,18 @@ Firebase offers two cloud-based, client-accessible database solutions that suppo ### Authenticate users - [Getting started with Firebase Authentication](docs/auth/getting-started.md) +- [Route users with AngularFire guards](docs/auth/router-guards.md) ### Upload files + - [Getting started with Cloud Storage](docs/storage/storage.md) ### Send push notifications + - [Getting started with Firebase Messaging](docs/messaging/messaging.md) ### Directly call Cloud Functions + - [Getting started with Callable Functions](docs/functions/functions.md) ### Deploying your application diff --git a/docs/auth/router-guards.md b/docs/auth/router-guards.md new file mode 100644 index 000000000..dfe762a51 --- /dev/null +++ b/docs/auth/router-guards.md @@ -0,0 +1,102 @@ +# Route users with AngularFire guards + +`AngularFireAuthGuard` provides a prebuilt [`canActivate` Router Guard](https://angular.io/api/router/CanActivate) using `AngularFireAuth`. By default unauthenticated users are not permitted to navigate to protected routes: + +```ts +import { AngularFireAuthGuard } from '@angular/fire/auth-guard'; + +export const routes: Routes = [ + { path: '', component: AppComponent }, + { path: 'items', component: ItemListComponent, canActivate: [AngularFireAuthGuard] }, +] +``` + +## Customizing the behavior of `AngularFireAuthGuard` + +To customize the behavior of `AngularFireAuthGuard`, you can pass an RXJS pipe through the route data's `authGuardPipe` key. + +The `auth-guard` module provides the following pre-built pipes: + +| Exported pipe | Functionality | +|-|-| +| `loggedIn` | The default pipe, rejects if the user is not authenticated. | +| `isNotAnonymous` | Rejects if the user is anonymous | +| `emailVerified` | Rejects if the user's email is not verified | +| `hasCustomClaim(claim)` | Rejects if the user does not have the specified claim | +| `redirectUnauthorizedTo(redirect)` | Redirect unauthenticated users to a different route | +| `redirectLoggedInTo(redirect)` | Redirect authenticated users to a different route | + +Example use: + +```ts +import { AngularFireAuthGuard, hasCustomClaim, redirectUnauthorizedTo, redirectLoggedInTo } from '@angular/fire/auth-guard'; + +const adminOnly = hasCustomClaim('admin'); +const redirectUnauthorizedToLogin = redirectUnauthorizedTo(['login']); +const redirectLoggedInToItems = redirectLoggedInTo(['items']); +const belongsToAccount = (next) => hasCustomClaim(`account-${next.params.id}`); + +export const routes: Routes = [ + { path: '', component: AppComponent }, + { path: 'login', component: LoginComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: redirectLoggedInToItems }}, + { path: 'items', component: ItemListComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: redirectUnauthorizedToLogin }, + { path: 'admin', component: AdminComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: adminOnly }}, + { path: 'accounts/:id', component: AdminComponent, canActivate: [AngularFireAuthGuard], data: { authGuardPipe: belongsToAccount }} +]; +``` + +Use the provided `canActivate` helper and spread syntax to make your routes more readable: + +```ts +import { canActivate } from '@angular/fire/auth-guard'; + +export const routes: Routes = [ + { path: '', component: AppComponent }, + { path: 'login', component: LoginComponent, ...canActivate(redirectLoggedInToItems) }, + { path: 'items', component: ItemListComponent, ...canActivate(redirectUnauthorizedToLogin) }, + { path: 'admin', component: AdminComponent, ...canActivate(adminOnly) }, + { path: 'accounts/:id', component: AdminComponent, ...canActivate(belongsToAccount) } +]; +``` + +### Compose your own pipes + +`AngularFireAuthGuard` pipes are RXJS operators which transform an optional User to a boolean or Array (for redirects). You can build easily build your own to customize behavior further: + +```ts +import { map } from 'rxjs/operators'; + +// This pipe redirects a user to their "profile edit" page or the "login page" if they're unauthenticated +// { path: 'profile', ...canActivate(redirectToProfileEditOrLogin) } +const redirectToProfileEditOrLogin = map(user => user ? ['profiles', user.uid, 'edit'] : ['login']); +``` + +The `auth-guard` modules provides a `customClaims` operator to reduce boiler plate when checking a user's claims: + +```ts +import { pipe } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { customClaims } from '@angular/fire/auth-guard'; + +// This pipe will only allow users with the editor role to access the route +// { path: 'articles/:id/edit', component: ArticleEditComponent, ...canActivate(editorOnly) } +const editorOnly = pipe(customClaims, map(claims => claims.role === "editor")); +``` + +### Using router state + +`AngularFireAuthGuard` will also accept `AuthPipeGenerator`s which generate `AuthPipe`s given the router state: + +```ts +import { pipe } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { customClaims } from '@angular/fire/auth-guard'; + +// Only allow navigation to the route if :userId matches the authenticated user's uid +// { path: 'user/:userId/edit', component: ProfileEditComponent, ...canActivate(onlyAllowSelf) } +const onlyAllowSelf = (next) => map(user => !!user && next.params.userId === user.uid); + +// Only allow navigation to the route if the user has a custom claim matching :accountId +// { path: 'accounts/:accountId/billing', component: BillingDetailsComponent, ...canActivate(accountAdmin) } +const accountAdmin = (next) => pipe(customClaims, map(claims => claims[`account-${next.params.accountId}-role`] === "admin")); +``` \ No newline at end of file diff --git a/karma.conf.js b/karma.conf.js index b07723d38..741435dd8 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -33,6 +33,7 @@ module.exports = function(config) { 'node_modules/firebase/firebase-storage.js', 'dist/packages-dist/bundles/core.umd.{js,map}', 'dist/packages-dist/bundles/auth.umd.{js,map}', + 'dist/packages-dist/bundles/auth-guard.umd.{js,map}', 'dist/packages-dist/bundles/database.umd.{js,map}', 'dist/packages-dist/bundles/firestore.umd.{js,map}', 'dist/packages-dist/bundles/functions.umd.{js,map}', diff --git a/package.json b/package.json index 447a637c0..0a500276f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@angular/core": ">=6.0.0 <9 || 9.0.0-0", "@angular/platform-browser": ">=6.0.0 <9 || 9.0.0-0", "@angular/platform-browser-dynamic": ">=6.0.0 <9 || 9.0.0-0", + "@angular/router": ">=6.0.0 <9 || 9.0.0-0", "firebase": ">= 5.5.7 <7", "firebase-tools": "^6.10.0", "fuzzy": "^0.1.3", diff --git a/src/auth-guard/auth-guard.module.ts b/src/auth-guard/auth-guard.module.ts new file mode 100644 index 000000000..c107f720e --- /dev/null +++ b/src/auth-guard/auth-guard.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { AngularFireAuthGuard } from './auth-guard'; + +@NgModule({ + providers: [ AngularFireAuthGuard ] +}) +export class AngularFireAuthGuardModule { } diff --git a/src/auth-guard/auth-guard.spec.ts b/src/auth-guard/auth-guard.spec.ts new file mode 100644 index 000000000..efe5115d2 --- /dev/null +++ b/src/auth-guard/auth-guard.spec.ts @@ -0,0 +1,40 @@ +import { TestBed, inject } from '@angular/core/testing'; +import { FirebaseApp, AngularFireModule } from '@angular/fire'; +import { COMMON_CONFIG } from './test-config'; +import { AngularFireAuthModule } from '@angular/fire/auth'; +import { AngularFireAuthGuardModule, AngularFireAuthGuard } from '@angular/fire/auth-guard'; +import { RouterModule, Router } from '@angular/router'; +import { APP_BASE_HREF } from '@angular/common'; + +describe('AngularFireAuthGuard', () => { + let app: FirebaseApp; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFireAuthModule, + AngularFireAuthGuardModule, + RouterModule.forRoot([ + { path: 'a', redirectTo: '/', canActivate: [AngularFireAuthGuard] } + ]) + ], + providers: [ + { provide: APP_BASE_HREF, useValue: 'http://localhost:4200/' } + ] + }); + inject([FirebaseApp, Router], (app_: FirebaseApp, router_: Router) => { + app = app_; + router = router_; + })(); + }); + + afterEach(done => { + app.delete().then(done, done.fail); + }); + + it('should be injectable', () => { + expect(router).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/auth-guard/auth-guard.ts b/src/auth-guard/auth-guard.ts new file mode 100644 index 000000000..99e9cffb7 --- /dev/null +++ b/src/auth-guard/auth-guard.ts @@ -0,0 +1,38 @@ +import { Injectable, InjectionToken } from '@angular/core'; +import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router'; +import { Observable, of, pipe, UnaryFunction } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators' +import { User, auth } from 'firebase/app'; +import { AngularFireAuth } from '@angular/fire/auth'; + +export type AuthPipeGenerator = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => AuthPipe; +export type AuthPipe = UnaryFunction, Observable>; + +@Injectable() +export class AngularFireAuthGuard implements CanActivate { + + constructor(private afAuth: AngularFireAuth, private router: Router) {} + + canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + const authPipeFactory: AuthPipeGenerator = next.data.authGuardPipe || (() => loggedIn); + return this.afAuth.user.pipe( + take(1), + authPipeFactory(next, state), + map(canActivate => typeof canActivate == "boolean" ? canActivate : this.router.createUrlTree(canActivate)) + ); + } + +} + +export const canActivate = (pipe: AuthPipe|AuthPipeGenerator) => ({ + canActivate: [ AngularFireAuthGuard ], data: { authGuardPipe: pipe.name === "" ? pipe : () => pipe} +}); + +export const loggedIn: AuthPipe = map(user => !!user); +export const isNotAnonymous: AuthPipe = map(user => !!user && !user.isAnonymous); +export const idTokenResult = switchMap((user: User|null) => user ? user.getIdTokenResult() : of(null)); +export const emailVerified: AuthPipe = map(user => !!user && user.emailVerified); +export const customClaims = pipe(idTokenResult, map(idTokenResult => idTokenResult ? idTokenResult.claims : [])); +export const hasCustomClaim = (claim:string) => pipe(customClaims, map(claims => claims.hasOwnProperty(claim))); +export const redirectUnauthorizedTo = (redirect: any[]) => pipe(loggedIn, map(loggedIn => loggedIn || redirect)); +export const redirectLoggedInTo = (redirect: any[]) => pipe(loggedIn, map(loggedIn => loggedIn && redirect || true)); \ No newline at end of file diff --git a/src/auth-guard/index.spec.ts b/src/auth-guard/index.spec.ts new file mode 100644 index 000000000..bb7912cc2 --- /dev/null +++ b/src/auth-guard/index.spec.ts @@ -0,0 +1 @@ +import './auth-guard.spec'; diff --git a/src/auth-guard/index.ts b/src/auth-guard/index.ts new file mode 100644 index 000000000..9a85f300e --- /dev/null +++ b/src/auth-guard/index.ts @@ -0,0 +1 @@ +export * from './public_api'; \ No newline at end of file diff --git a/src/auth-guard/package.json b/src/auth-guard/package.json new file mode 100644 index 000000000..d5690aa8c --- /dev/null +++ b/src/auth-guard/package.json @@ -0,0 +1,32 @@ +{ + "name": "@angular/fire/auth-guard", + "version": "ANGULARFIRE2_VERSION", + "description": "The auth guard module", + "main": "../bundles/auth-guard.umd.js", + "module": "index.js", + "es2015": "./es2015/index.js", + "keywords": [ + "angular", + "firebase", + "rxjs" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/angular/angularfire2.git" + }, + "author": "angular,firebase", + "license": "MIT", + "peerDependencies": { + "@angular/fire": "ANGULARFIRE2_VERSION", + "@angular/common": "ANGULAR_VERSION", + "@angular/core": "ANGULAR_VERSION", + "@angular/platform-browser": "ANGULAR_VERSION", + "@angular/platform-browser-dynamic": "ANGULAR_VERSION", + "@angular/router": "ANGULAR_VERSION", + "firebase": "FIREBASE_VERSION", + "rxjs": "RXJS_VERSION", + "zone.js": "ZONEJS_VERSION" + }, + "typings": "index.d.ts" + } + \ No newline at end of file diff --git a/src/auth-guard/public_api.ts b/src/auth-guard/public_api.ts new file mode 100644 index 000000000..f939652ac --- /dev/null +++ b/src/auth-guard/public_api.ts @@ -0,0 +1,2 @@ +export * from './auth-guard'; +export * from './auth-guard.module'; \ No newline at end of file diff --git a/src/auth-guard/test-config.ts b/src/auth-guard/test-config.ts new file mode 100644 index 000000000..4b69c98dd --- /dev/null +++ b/src/auth-guard/test-config.ts @@ -0,0 +1,7 @@ + +export const COMMON_CONFIG = { + apiKey: "AIzaSyBVSy3YpkVGiKXbbxeK0qBnu3-MNZ9UIjA", + authDomain: "angularfire2-test.firebaseapp.com", + databaseURL: "https://angularfire2-test.firebaseio.com", + storageBucket: "angularfire2-test.appspot.com", +}; diff --git a/src/auth-guard/tsconfig-build.json b/src/auth-guard/tsconfig-build.json new file mode 100644 index 000000000..fcf2e7c78 --- /dev/null +++ b/src/auth-guard/tsconfig-build.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "module": "es2015", + "target": "es2015", + "noImplicitAny": false, + "outDir": "../../dist/packages-dist/auth-guard/es2015", + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "declaration": false, + "removeComments": true, + "strictNullChecks": true, + "lib": ["es2015", "dom", "es2015.promise", "es2015.collection", "es2015.iterable"], + "skipLibCheck": true, + "moduleResolution": "node", + "paths": { + "@angular/fire": ["../../dist/packages-dist"], + "@angular/fire/auth": ["../../dist/packages-dist/auth"] + } + }, + "files": [ + "index.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ], + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableSummariesForJit": false + } +} + diff --git a/src/auth-guard/tsconfig-esm.json b/src/auth-guard/tsconfig-esm.json new file mode 100644 index 000000000..80c35340b --- /dev/null +++ b/src/auth-guard/tsconfig-esm.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig-build.json", + "compilerOptions": { + "target": "es5", + "outDir": "../../dist/packages-dist/auth-guard", + "declaration": true + }, + "files": [ + "public_api.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ], + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableSummariesForJit": false, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/fire/auth-guard" + } +} diff --git a/src/auth-guard/tsconfig-test.json b/src/auth-guard/tsconfig-test.json new file mode 100644 index 000000000..f50870aa8 --- /dev/null +++ b/src/auth-guard/tsconfig-test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig-esm.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@angular/fire": ["../../dist/packages-dist"], + "@angular/fire/auth": ["../../dist/packages-dist/auth"], + "@angular/fire/auth-guard": ["../../dist/packages-dist/auth-guard"] + } + }, + "files": [ + "index.spec.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ] +} diff --git a/src/root.spec.js b/src/root.spec.js index 352ed6c06..a44c7a68b 100644 --- a/src/root.spec.js +++ b/src/root.spec.js @@ -1,6 +1,7 @@ // These paths are written to use the dist build export * from './packages-dist/angularfire2.spec'; export * from './packages-dist/auth/auth.spec'; +export * from './packages-dist/auth-guard/auth-guard.spec'; export * from './packages-dist/firestore/firestore.spec'; export * from './packages-dist/firestore/document/document.spec'; export * from './packages-dist/firestore/collection/collection.spec'; diff --git a/src/tsconfig.json b/src/tsconfig.json index fd90550e8..42b5cd977 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -12,6 +12,7 @@ "paths": { "@angular/fire": ["./core"], "@angular/fire/auth": ["./auth"], + "@angular/fire/auth-guard": ["./auth-guard"], "@angular/fire/database": ["./database"], "@angular/fire/firestore": ["./firestore"], "@angular/fire/functions": ["./functions"], diff --git a/tools/build.js b/tools/build.js index a1ec20271..41c8bfcc4 100644 --- a/tools/build.js +++ b/tools/build.js @@ -30,6 +30,7 @@ const GLOBALS = { 'firebase/storage': 'firebase', '@angular/fire': 'angularfire2', '@angular/fire/auth': 'angularfire2.auth', + '@angular/fire/auth-guard': 'angularfire2.auth_guard', '@angular/fire/database': 'angularfire2.database', '@angular/fire/database-deprecated': 'angularfire2.database_deprecated', '@angular/fire/firestore': 'angularfire2.firestore', @@ -63,6 +64,7 @@ const VERSIONS = { const MODULE_NAMES = { core: 'angularfire2', auth: 'angularfire2.auth', + "auth-guard": 'angularfire2.auth_guard', database: 'angularfire2.database', "database-deprecated": 'angularfire2.database_deprecated', firestore: 'angularfire2.firestore', @@ -75,6 +77,7 @@ const MODULE_NAMES = { const ENTRIES = { core: `${process.cwd()}/dist/packages-dist/index.js`, auth: `${process.cwd()}/dist/packages-dist/auth/index.js`, + "auth-guard": `${process.cwd()}/dist/packages-dist/auth-guard/index.js`, database: `${process.cwd()}/dist/packages-dist/database/index.js`, "database-deprecated": `${process.cwd()}/dist/packages-dist/database-deprecated/index.js`, firestore: `${process.cwd()}/dist/packages-dist/firestore/index.js`, @@ -87,6 +90,7 @@ const ENTRIES = { const SRC_PKG_PATHS = { core: `${process.cwd()}/src/core/package.json`, auth: `${process.cwd()}/src/auth/package.json`, + "auth-guard": `${process.cwd()}/src/auth-guard/package.json`, database: `${process.cwd()}/src/database/package.json`, "database-deprecated": `${process.cwd()}/src/database-deprecated/package.json`, firestore: `${process.cwd()}/src/firestore/package.json`, @@ -99,6 +103,7 @@ const SRC_PKG_PATHS = { const DEST_PKG_PATHS = { core: `${process.cwd()}/dist/packages-dist/package.json`, auth: `${process.cwd()}/dist/packages-dist/auth/package.json`, + "auth-guard": `${process.cwd()}/dist/packages-dist/auth-guard/package.json`, database: `${process.cwd()}/dist/packages-dist/database/package.json`, "database-deprecated": `${process.cwd()}/dist/packages-dist/database-deprecated/package.json`, firestore: `${process.cwd()}/dist/packages-dist/firestore/package.json`, @@ -283,6 +288,7 @@ function getVersions() { const paths = [ getDestPackageFile('core'), getDestPackageFile('auth'), + getDestPackageFile('auth-guard'), getDestPackageFile('database'), getDestPackageFile('firestore'), getDestPackageFile('firebase-node'), @@ -323,6 +329,7 @@ function buildModule(name, globals) { function buildModules(globals) { const core$ = buildModule('core', globals); const auth$ = buildModule('auth', globals); + const authGuard$ = buildModule('auth-guard', globals); const db$ = buildModule('database', globals); const firestore$ = buildModule('firestore', globals); const functions$ = buildModule('functions', globals); @@ -333,6 +340,7 @@ function buildModules(globals) { return forkJoin(core$, from(copyRootTest())).pipe( switchMapTo(schematics$), switchMapTo(auth$), + switchMapTo(authGuard$), switchMapTo(db$), switchMapTo(firestore$), switchMapTo(functions$), @@ -355,6 +363,7 @@ function buildLibrary(globals) { tap(() => { const coreStats = measure('core'); const authStats = measure('auth'); + const authGuardStats = measure('auth-guard'); const dbStats = measure('database'); const fsStats = measure('firestore'); const functionsStats = measure('functions'); @@ -364,6 +373,7 @@ function buildLibrary(globals) { console.log(` core.umd.js - ${coreStats.size}, ${coreStats.gzip} auth.umd.js - ${authStats.size}, ${authStats.gzip} +auth-guard.umd.js - ${authGuardStats.size}, ${authGuardStats.gzip} database.umd.js - ${dbStats.size}, ${dbStats.gzip} firestore.umd.js - ${fsStats.size}, ${fsStats.gzip} functions.umd.js - ${functionsStats.size}, ${functionsStats.gzip} diff --git a/yarn.lock b/yarn.lock index 0bce166ee..398dd46a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -108,6 +108,13 @@ tslib "^1.9.0" xhr2 "^0.1.4" +"@angular/router@>=6.0.0 <9 || 9.0.0-0": + version "7.2.15" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-7.2.15.tgz#b2acbd07c17158801006cdd7e93113d6ec1f116e" + integrity sha512-qAubRJRQanguUqJQ76J9GSZ4JFtoyhJKRmX5P23ANZJXpB6YLzF2fJmOGi+E6cV8F0tKBMEq1pjxFTisx0MXwQ== + dependencies: + tslib "^1.9.0" + "@firebase/app-types@0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.4.0.tgz#bb2c651f3b275fef549050cff28af752839c75c0"