Revoked token implementation and GraphQL on frontend

This commit is contained in:
Norbi Peti 2022-05-02 23:01:19 +02:00
parent ff5cdb8e8a
commit 8471825e5d
No known key found for this signature in database
GPG key ID: DBA4C4549A927E56
24 changed files with 7734 additions and 1032 deletions

2
.gitignore vendored
View file

@ -48,3 +48,5 @@ testem.log
Thumbs.db
.firebase/
frontend/src/app/services/graphql.ts
frontend/graphql.schema.json

View file

@ -1,7 +1,7 @@
import { arg, authorized, GraphQLBindings, Int, mutation, query, resolver, ResolverData } from '@loopback/graphql';
import { User } from '../models';
import { repository } from '@loopback/repository';
import { UserRepository } from '../repositories';
import { RevTokenRepository, UserRepository } from '../repositories';
import { Context, inject } from '@loopback/core';
import { SzakdolgozatUserService } from '../services';
import { TokenServiceBindings, UserServiceBindings } from '@loopback/authentication-jwt';
@ -18,6 +18,7 @@ import { SzakdolgozatBindings } from '../bindings';
export class UserResolver {
constructor(
@repository('UserRepository') private readonly userRepository: UserRepository,
@repository('RevTokenRepository') private readonly revTokenRepo: RevTokenRepository,
@inject(UserServiceBindings.USER_SERVICE) private readonly userService: SzakdolgozatUserService,
@inject(GraphQLBindings.RESOLVER_DATA) private readonly resolverData: ResolverData,
@inject(TokenServiceBindings.TOKEN_SERVICE) public jwtService: TokenService,
@ -38,11 +39,6 @@ export class UserResolver {
return this.userRepository.create(user);
}
@query(returns => User)
async test(request: User): Promise<User> {
return (await this.userRepository.find())[0];
}
@mutation(returns => LoginResult)
async login(@arg('email') email: string, @arg('password') password: string): Promise<LoginResult> {
// ensure the user exists, and the password is correct
@ -59,10 +55,8 @@ export class UserResolver {
@mutation(returns => Boolean)
async logout(): Promise<boolean> {
const token = await this.context.get(SzakdolgozatBindings.AUTH_TOKEN);
if (this.jwtService.revokeToken) {
await this.jwtService.revokeToken(token);
} else {
console.error('Cannot revoke token');
if (token && !(await this.revTokenRepo.count({token})).count) {
await this.revTokenRepo.create({token, created: new Date()});
}
return true;
}

View file

@ -10,6 +10,12 @@ export class RevToken extends Entity {
})
token: string;
@property({
type: Date,
required: true
})
created: Date;
constructor(data?: Partial<RevToken>) {
super(data);

View file

@ -3,6 +3,8 @@ import { AuthChecker, ExpressContext } from '@loopback/graphql';
import { Context, Getter, inject, ValueOrPromise } from '@loopback/core';
import { JWTAuthenticationStrategy } from '@loopback/authentication-jwt';
import { SzakdolgozatBindings } from './bindings';
import { repository } from '@loopback/repository';
import { RevTokenRepository } from './repositories';
export class SzakdolgozatAuthChecker {
constructor() {
@ -12,13 +14,21 @@ export class SzakdolgozatAuthChecker {
static value(@inject(AuthenticationBindings.AUTH_ACTION) authenticate: AuthenticateFn,
@inject.getter(AuthenticationBindings.STRATEGY)
getStrategies: Getter<AuthenticationStrategy | AuthenticationStrategy[] | undefined>,
@inject.context() context: Context): ValueOrPromise<AuthChecker> {
@inject.context() context: Context,
@repository('RevTokenRepository') revTokenRepo: RevTokenRepository): ValueOrPromise<AuthChecker> {
return async (resolverData, roles) => {
const econtext = (<ExpressContext> resolverData.context);
const res = await authenticate(econtext.req);
const strat = <JWTAuthenticationStrategy> await getStrategies();
// Itt már biztosan van érvényes token
context.bind(SzakdolgozatBindings.AUTH_TOKEN).to(strat.extractCredentials(econtext.req));
const token = strat.extractCredentials(econtext.req);
const date = new Date();
date.setMonth(date.getMonth() - 1);
await revTokenRepo.deleteAll({created: {lt: date}});
if ((await revTokenRepo.count({token})).count) {
throw new Error('Session expired. Please sign in again.');
}
context.bind(SzakdolgozatBindings.AUTH_TOKEN).to(token);
return true;
};
}

15
frontend/.graphqlconfig Normal file
View file

@ -0,0 +1,15 @@
{
"name": "Untitled GraphQL Schema",
"schemaPath": "./graphql.schema.json",
"extensions": {
"endpoints": {
"Default GraphQL Endpoint": {
"url": "http://localhost:8019/graphql",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": true
}
}
}
}

View file

@ -0,0 +1,20 @@
module.exports = {
schema: 'http://backend:3000/graphql',
documents: "src/app/graphql/**/*.graphql",
extensions: {
codegen: {
generates: {
"src/app/services/graphql.ts": {
plugins: [
"typescript",
"typescript-operations",
"typescript-apollo-angular"
]
},
"./graphql.schema.json": {
plugins: ["introspection"]
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@
"buildProd": "ng build --prod",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
"e2e": "ng e2e",
"gql": "graphql-codegen --config graphql.config.js"
},
"private": true,
"dependencies": {
@ -23,7 +24,16 @@
"@angular/platform-browser": "^11.0.4",
"@angular/platform-browser-dynamic": "^11.0.4",
"@angular/router": "^11.0.4",
"@apollo/client": "^3.0.0",
"@graphql-codegen/cli": "^2.6.2",
"@graphql-codegen/introspection": "^2.1.1",
"@graphql-codegen/typescript": "^2.4.8",
"@graphql-codegen/typescript-apollo-angular": "^3.4.7",
"@graphql-codegen/typescript-operations": "^2.3.5",
"@types/react": "^18.0.8",
"apollo-angular": "^2.6.0",
"firebase": "^8.2.0",
"graphql": "^15.0.0",
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.10.2"

View file

@ -4,8 +4,7 @@ import { Observable } from 'rxjs';
import { filter, map, shareReplay } from 'rxjs/operators';
import { LoginService } from './auth/login.service';
import { ActivatedRoute, ActivatedRouteSnapshot, NavigationEnd, Route, Router, Routes } from '@angular/router';
import { ApiService } from './api.service';
import { UserRole } from './model/user.model';
import { ApiService } from './services/api.service';
import { RouteData } from './app-routing.module';
import { Title } from '@angular/platform-browser';
@ -138,7 +137,7 @@ export class AppComponent implements OnInit {
}
type MenuItem = { path: string, requiredRole: UserRole | 'admin', title?: string };
type MenuItem = { path: string, requiredRole: 'admin', title?: string }; // TODO: Role
type RouteSegment = { title: string, url: string };
export interface CustomTitleComponent {

View file

@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { LoginService } from './auth/login.service';
import { HttpClientModule } from '@angular/common/http';
import { AuthCheck } from './auth-check';
import { GraphQLModule } from './graphql.module';
@NgModule({
declarations: [
@ -37,7 +38,8 @@ import { AuthCheck } from './auth-check';
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
HttpClientModule
HttpClientModule,
GraphQLModule
],
providers: [
LoginService,

View file

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { User } from '../model/user.model';
import { LoginGQL, UserResult } from '../services/graphql';
@Injectable({
providedIn: 'root'
@ -9,17 +9,17 @@ import { User } from '../model/user.model';
export class LoginService {
private tokenP: string;
private userP: User;
private userP: UserResult;
get token(): string {
return this.tokenP;
}
get user(): User {
get user(): UserResult {
return this.userP;
}
constructor(private http: HttpClient) {
constructor(private http: HttpClient, private loginService: LoginGQL) {
this.tokenP = window.localStorage.getItem('token');
this.userP = JSON.parse(window.localStorage.getItem('user'));
}
@ -30,14 +30,11 @@ export class LoginService {
async login(email: string, password: string): Promise<boolean> {
try {
const resp = await this.http.post<{ token: string, user: User }>(environment.backendUrl + '/users/login', {
email,
password
}).toPromise();
this.tokenP = resp.token;
this.userP = resp.user;
window.localStorage.setItem('token', resp.token);
window.localStorage.setItem('user', JSON.stringify(resp.user));
const resp = await this.loginService.mutate({email, password}).toPromise();
this.tokenP = resp.data.login.token;
this.userP = resp.data.login.user;
window.localStorage.setItem('token', this.tokenP);
window.localStorage.setItem('user', JSON.stringify(this.userP));
return true;
} catch (e) {
if (e.status === 401 || e.status === 422) {

View file

@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { APOLLO_OPTIONS } from 'apollo-angular';
import { ApolloClientOptions, InMemoryCache } from '@apollo/client/core';
import { HttpLink } from 'apollo-angular/http';
import { environment } from '../environments/environment';
const uri = environment.backendUrl + '/graphql'; // <-- add the URL of the GraphQL server here
export function createApollo(httpLink: HttpLink): ApolloClientOptions<any> {
return {
link: httpLink.create({uri}),
cache: new InMemoryCache(),
};
}
@NgModule({
providers: [
{
provide: APOLLO_OPTIONS,
useFactory: createApollo,
deps: [HttpLink],
},
],
})
export class GraphQLModule {
}

View file

@ -0,0 +1,11 @@
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
user {
email
id
isAdmin
name
}
}
}

View file

@ -1,7 +0,0 @@
import { Model } from './model';
export class Course extends Model {
semester: string;
subjectId: number;
alias: string;
}

View file

@ -1,3 +0,0 @@
export class Model {
id: number;
}

View file

@ -1,6 +0,0 @@
import { Model } from './model';
export class Subject extends Model {
name: string;
description: string;
}

View file

@ -1,10 +0,0 @@
import { Model } from './model';
export class User extends Model {
name: string;
email: string;
isAdmin = false;
}
export type UserRole = 'teacher' | 'student';

View file

@ -1,22 +1,27 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../environments/environment';
import { LoginService } from './auth/login.service';
import { LoginService } from '../auth/login.service';
import { Router } from '@angular/router';
import { Apollo } from 'apollo-angular';
import { gql } from '@apollo/client';
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor(private http: HttpClient, private loginService: LoginService, private router: Router) {
constructor(private apollo: Apollo, private loginService: LoginService, private router: Router) {
}
request(method: 'post' | 'get' | 'delete' | 'patch', url: string, body: any): Promise<any> {
return this.http.request(method, environment.backendUrl + url, {
body,
headers: {Authorization: 'Bearer ' + this.loginService.token}
}).toPromise().catch(e => {
const asd = this.apollo.query({
query: gql`
{
__typename
}
`
// headers: {Authorization: 'Bearer ' + this.loginService.token}
}).toPromise().then(res => res);
return asd.catch(e => {
if (e.status === 401) {
this.loginService.deleteToken();
return this.router.navigateByUrl('/auth/login');

View file

@ -1,5 +1,5 @@
import { Component, Input, OnInit, Type } from '@angular/core';
import { ApiService } from '../../api.service';
import { ApiService } from '../../services/api.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Model } from '../../model/model';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';

View file

@ -1,8 +1,7 @@
import { Component, Input, OnInit, Type } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { PaginationData } from '../../utility/pagination-data';
import { ApiService } from '../../api.service';
import { Model } from '../../model/model';
import { ApiService } from '../../services/api.service';
import { Router } from '@angular/router';
@Component({
@ -10,10 +9,10 @@ import { Router } from '@angular/router';
templateUrl: './list.component.html',
styleUrls: ['./list.component.css']
})
export class ListComponent<T extends Model> implements OnInit {
export class ListComponent<T extends { id: number }> implements OnInit {
@Input() apiPath: string;
@Input() itemType: Type<T>;
@Input() itemType: T;
@Input() columns: { title: string, prop: keyof T }[];
@Input() allowNew = false;
@Input() customActions: { icon: string, label: string, action: (model: T) => void }[] = [];

View file

@ -1,5 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Course } from '../../../../model/course.model';
import { ActivatedRoute } from '@angular/router';
@Component({
@ -8,7 +7,7 @@ import { ActivatedRoute } from '@angular/router';
styleUrls: ['./course-edit.component.css']
})
export class CourseEditComponent implements OnInit {
itemType = Course;
itemType: Course;
beforeSubmit = () => ({subjectId: +this.route.snapshot.params.subjectId});
constructor(private route: ActivatedRoute) {

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { User } from '../../model/user.model';
import { UserResult } from '../../services/graphql';
@Component({
selector: 'app-user-list',
@ -7,7 +7,7 @@ import { User } from '../../model/user.model';
styleUrls: ['./user-list.component.css']
})
export class UserListComponent implements OnInit {
itemType = User;
itemType: UserResult;
constructor() {
}

View file

@ -1,4 +1,3 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
@ -14,7 +13,9 @@
"module": "es2020",
"lib": [
"es2018",
"dom"
]
"dom",
"esnext.asynciterable"
],
"allowSyntheticDefaultImports": true
}
}
}