Compare commits

1 Commits

Author SHA1 Message Date
eneller
d79a75d34f fix: auth guard 2026-04-11 01:41:53 +02:00
41 changed files with 124 additions and 488 deletions

View File

@@ -1,17 +1,6 @@
# FakeMoney
**A PayPal-like payment processor for virtual money, intended to be used for [simulation games](https://de.wikipedia.org/wiki/Schule_als_Staat).**
A PayPal-like payment processor for virtual money, intended to be used for simulation games.
Send and receive money from your account and businesses you own using simple URLs and QR codes.
Install as a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
from your browser (Firefox/Chrome/Safari) with just 3 taps and bypass tedious app store processes.
<img src="docs/img/screenshot-login.png" width="32%" alt="Login view">&nbsp;
<img src="docs/img/screenshot-send.png" width="32%" alt="Send view">&nbsp;
<img src="docs/img/screenshot-receive.png" width="32%" alt="Receive view">&nbsp;
## Deployment
Is simplified using Docker and by making a few assumptions, e.g. that API and frontend are available under the same domain.
## Development
### Frontend

View File

@@ -3,8 +3,6 @@ import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './services/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
@@ -12,6 +10,5 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes),
{provide: DEFAULT_CURRENCY_CODE, useValue: ''},
{provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: {dateFormat: 'shortDate'}},
provideHttpClient(withInterceptors([authInterceptor])),
]
};

View File

@@ -1,5 +1,4 @@
<main class="main">
<app-toast-container></app-toast-container>
<router-outlet></router-outlet>
</main>
<nav>

View File

@@ -1,4 +1,4 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { Component, OnInit, signal } from '@angular/core';
import { RouterOutlet, RouterLinkWithHref, Router, NavigationEnd } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {
@@ -8,31 +8,27 @@ import {
NgbNavLinkBase,
} from '@ng-bootstrap/ng-bootstrap/nav';
import { filter } from 'rxjs';
import { APIService } from './services/api';
import { ToastContainer } from "./components/toast-container/toast-container";
@Component({
selector: 'app-root',
imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref, ToastContainer],
imports: [RouterOutlet, NgbModule, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLinkBase, RouterLinkWithHref],
templateUrl: './app.html',
styleUrl: './app.less'
})
export class App implements OnInit{
protected readonly title = signal('client');
protected api = inject(APIService);
active = '/';
constructor(
private router: Router
private router: Router,
){}
ngOnInit(): void {
this.api.checkAuthStatus().subscribe();
// show correct active tab
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() =>{
this.active = this.router.url;
console.log(this.active);
});
}

View File

@@ -1,13 +0,0 @@
<div class="toast-container position-fixed top-0 end-0 p-3">
@for (notification of notifications.notifications(); track notification) {
<ngb-toast
[autohide]="true"
[delay]="notification.delay ?? 3000"
(hidden)="notifications.remove(notification)"
>
<div class="text-{{ notification.type }}">
{{ notification.message }}
</div>
</ngb-toast>
}
</div>

View File

@@ -1,22 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToastContainer } from './toast-container';
describe('ToastContainer', () => {
let component: ToastContainer;
let fixture: ComponentFixture<ToastContainer>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastContainer],
}).compileComponents();
fixture = TestBed.createComponent(ToastContainer);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,13 +0,0 @@
import { Component, inject } from '@angular/core';
import { NgbToastModule } from '@ng-bootstrap/ng-bootstrap';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-toast-container',
imports: [NgbToastModule],
templateUrl: './toast-container.html',
styleUrl: './toast-container.less',
})
export class ToastContainer {
notifications = inject(NotificationService);
}

View File

@@ -48,7 +48,7 @@
type="submit"
class="btn btn-primary w-100 mb-3"
>
@if (loading()) {
@if (loading) {
<span> Signing In... </span>
}@else {
<span>Sign In</span>
@@ -57,15 +57,15 @@
<!-- Error Alert -->
@if (error()) {
<ngb-alert
*ngIf="error"
type="danger"
(closed)="error.set(null)"
(closed)="error = null"
[dismissible]="true"
>
{{ error() }}
{{ error }}
</ngb-alert>
}
</form>
</div>
</div>

View File

@@ -1,11 +1,9 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Component } from '@angular/core';
import { Validators, FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Form } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { APIService } from '../../services/api';
import { GenericMessage } from '@message/Message';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-screen-login',
@@ -16,13 +14,12 @@ import { NotificationService } from '../../services/notification';
export class ScreenLogin {
loginForm: FormGroup;
submitted = false;
loading = signal(false);
loading = false;
showPassword = false;
error = signal<string|null>(null);
error: string | null = null;
constructor(
private api: APIService,
private notify: NotificationService,
private router: Router,
private route: ActivatedRoute,
private fb: FormBuilder,
@@ -35,20 +32,21 @@ export class ScreenLogin {
onSubmit() {
this.submitted = true;
this.error.set(null);
this.loading.set(true)
this.error = null;
this.loading = true;
this.api.login(this.loginForm.value.username, this.loginForm.value.password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
},
error: (resp) => {
let msg: GenericMessage = resp.error;
this.notify.error(msg.message || 'Login failed. Please try again.');
this.loading.set(false);
error: (err) => {
//FIXME error message displaying delayed, display message from server response
this.error = err.error?.message || 'Login failed. Please try again.';
this.loading = false;
}
});
this.api.checkAuthStatus().subscribe();
}
}

View File

@@ -4,23 +4,12 @@
<div class="card-body text-center p-4">
<div class="avatar avatar-xl mb-3">
<i class="bi bi-person-circle fs-1 text-primary"></i>
<b> {{this.api.loggedInUser.displayName}} </b>
</div>
<div ngbDropdown class="d-inline-block">
<button type="button" class="btn btn-outline-primary" id="dropdownBasic1" ngbDropdownToggle>
{{ this.api.currentUser.displayName }}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
@for (acc of this.api.ownedAccounts; track $index) {
<button ngbDropdownItem (click)="this.api.currentUser = acc">{{ acc.displayName }}</button>
}
</div>
</div>
<h3 class="mb-1"></h3>
</div>
<h3 class="mb-1">{{ username }}</h3>
<p class="text-muted mb-4">{{ userID }}</p>
<div class="d-flex align-items-center justify-content-center gap-2 mb-1">
<i class="bi bi-wallet2 fs-4"></i>
<h3 class="mb-0">{{ this.api.currentUser.balance | currency}}</h3>
<h3 class="mb-0">{{ balance | currency}}</h3>
</div>
<button type="button" (click)="logOut()" class="btn btn-outline-secondary">Log out</button>
</div>
@@ -34,7 +23,7 @@
<div class="card-body p-0">
<div class="list-group list-group-flush">
<!-- Transaction Item -->
@for (transaction of transactions(); track $index) {
@for (transaction of transactions; track $index) {
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
@@ -42,7 +31,7 @@
<i class="bi bi-person-fill fs-4 text-secondary"></i>
</div>
<div class="text-start">
@if (transaction.receiverID == this.api.currentUser.id) {
@if (transaction.receiverID == userID) {
<h6 class="mb-0">{{ transaction.senderID }}</h6>
}@else {
<h6 class="mb-0">{{ transaction.receiverID }}</h6>
@@ -51,7 +40,7 @@
</div>
</div>
<div class="text-end">
@if (transaction.receiverID == this.api.currentUser.id) {
@if (transaction.receiverID == userID) {
<h6 class="mb-0 text-success">
{{ transaction.amount | currency }}
</h6>
@@ -64,7 +53,6 @@
</div>
</div>
}
@empty {<p class="text-muted">No transactions yet</p>}
</div>
</div>
</div>

View File

@@ -1,28 +1,31 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, inject, OnInit, signal } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { APIService } from '../../services/api';
import Transaction from '@model/transaction';
import { Router } from '@angular/router';
import { NgbAccordionToggle, NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-screen-profile',
imports: [CurrencyPipe, DatePipe, CommonModule, NgbDropdown, NgbDropdownMenu, NgbDropdownToggle, NgbDropdownItem],
imports: [CurrencyPipe, DatePipe, CommonModule],
templateUrl: './screen-profile.html',
styleUrl: './screen-profile.less',
})
export class ScreenProfile implements OnInit{
transactions = signal<Transaction[]>([])
username = 'John Doe';
userID = 'testuser';
balance = 200;
transactions!: Transaction[];
constructor(
protected api: APIService,
private api: APIService,
private router: Router,
){}
ngOnInit(): void {
// FIXME transactions displaying delayed (only on second nav click)
this.api.getTransactions().subscribe({
next: (transactions) => {
this.transactions.set(transactions);
this.transactions = transactions;
},
error: (err) => {
console.error('Error fetching transactions:', err);
@@ -32,7 +35,7 @@ export class ScreenProfile implements OnInit{
logOut(){
this.api.logout().subscribe({
next: () => {
this.router.navigate(['/'])
this.router.navigate(['login'])
},
error: (err) => {
console.error('Error logging out:', err)

View File

@@ -40,7 +40,7 @@
<ng-template #content let-modal>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Pay {{ amount}} to {{ this.api.currentUser.id }}</h4>
<h4 class="modal-title" id="modal-basic-title">Pay {{ amount}} to {{ user }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss()"></button>
</div>
<div class="modal-body text-center">

View File

@@ -2,7 +2,6 @@ import { Component, inject, TemplateRef } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { QrCodeComponent } from 'ng-qrcode';
import { APIService } from '../../services/api';
@Component({
selector: 'app-screen-receive',
@@ -12,12 +11,12 @@ import { APIService } from '../../services/api';
})
export class ScreenReceive {
private modalService = inject(NgbModal);
api = inject(APIService);
user = 'DemoUser';
amount: number = 0;
get shareableLink(): string {
const currentDomain = window.location.origin;
return `${currentDomain}/send/${this.api.currentUser.id}?amount=${this.amount}`;
return `${currentDomain}/send/${this.user}?amount=${this.amount}`;
}
copyLink() {

View File

@@ -28,7 +28,7 @@
<input
type="text"
class="form-control"
placeholder="username"
placeholder="Email or phone number"
[(ngModel)]="recipient"
/>
</div>
@@ -45,7 +45,7 @@
<button class="btn btn-primary btn-lg" (click)="sendMoney()">
Send Money
</button>
<button class="btn btn-outline-secondary btn-lg" (click)="clear()">
<button class="btn btn-outline-secondary btn-lg" (click)="cancel()">
Cancel
</button>
</div>

View File

@@ -1,7 +1,6 @@
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { APIService } from '../../services/api';
import { NotificationService } from '../../services/notification';
@Component({
selector: 'app-screen-send',
@@ -16,30 +15,21 @@ export class ScreenSend {
constructor(
private api: APIService,
private notify: NotificationService,
){}
sendMoney() {
this.api.send(this.amount, this.recipient, this.reference).subscribe({
next:()=> {
this.notify.success(`Sent ${this.amount} to ${this.recipient}`);
this.clear()
this.cancel()
//TODO show success message
},
error:(err)=> {
if(err.status == 404){
this.notify.error(`Invalid recipient "${this.recipient}"`);
}
else if(err.status == 402){
this.notify.error(`Insufficient funds.`);
}
else{
this.notify.error(`An error occurred during payment: ${err.status}`);
}
error:()=> {
//TODO show error message
}
});
}
clear() {
cancel() {
this.amount = 0;
this.recipient = '';
this.reference = '';

View File

@@ -3,8 +3,7 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject, catchError, map, Observable, of, tap } from 'rxjs';
import Transaction from '@model/transaction'
import { SendRequest, SendResponse } from '@message/Send';
import { LoginResponse } from '@message/Login';
import Account from '@model/user';
import { TransactionsRequest } from '@message/Transactions';
@Injectable({
providedIn: 'root',
@@ -14,44 +13,21 @@ export class APIService {
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
// data holding
loggedInUser!: Account;
currentUser!: Account;
ownedAccounts!: Account[];
constructor(private http: HttpClient){}
login(username: string, password: string): Observable<any>{
return this.http.post<LoginResponse>(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password}).pipe(
tap({
next: (resp) => {
this.isAuthenticatedSubject.next(true);
this.loggedInUser = resp.user;
this.currentUser = this.loggedInUser;
this.ownedAccounts = resp.ownedAccounts;
this.ownedAccounts.push(this.loggedInUser);
},
error: () => this.isAuthenticatedSubject.next(false)
})
);
return this.http.post(`${this.apiUrl}/auth/login`,{ 'username': username, 'password': password});
}
logout(): Observable<any>{
return this.http.post(`${this.apiUrl}/auth/logout`, {}).pipe(
tap({
next: () => this.isAuthenticatedSubject.next(false),
error: () => this.isAuthenticatedSubject.next(false),
})
);
}
clearAuthState() {
this.isAuthenticatedSubject.next(false);
return this.http.post(`${this.apiUrl}/auth/logout`, {});
}
checkAuthStatus(): Observable<boolean> {
return this.http.get(`${this.apiUrl}/auth/status`).pipe(
map(() => true),
catchError(() => of(false)),
tap( authenticated => {
this.isAuthenticatedSubject.next(authenticated);
tap({
next: () => this.isAuthenticatedSubject.next(true),
error: () => this.isAuthenticatedSubject.next(false),
})
);
}
@@ -59,8 +35,7 @@ export class APIService {
return this.http.get<Transaction[]>(`${this.apiUrl}/transactions`);
}
send(amount: number, recipientID: string, reference: string = ""): Observable<SendResponse>{
let request: SendRequest = {senderID: this.currentUser.id, amount, recipientID, reference};
let request: SendRequest = {amount, recipientID, reference};
return this.http.post<SendResponse>(`${this.apiUrl}/send`, request);
}
}

View File

@@ -1,14 +1,14 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { APIService } from './api';
import { map, take } from 'rxjs/operators';
import { first, map } from 'rxjs/operators';
export const authGuard: CanActivateFn = (route, state) => {
const api = inject(APIService);
const router = inject(Router);
return api.isAuthenticated$.pipe(
take(1),
return api.checkAuthStatus().pipe(
first(),
map((isAuthenticated) => {
if (isAuthenticated) {
return true;

View File

@@ -1,17 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { authInterceptor } from './auth-interceptor';
describe('authInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});

View File

@@ -1,19 +0,0 @@
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
import { APIService } from './api';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const api = inject(APIService);
const router = inject(Router)
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401){
api.clearAuthState();
router.createUrlTree(['/login']);
}
return throwError(() => err);
})
)
};

View File

@@ -1,16 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification';
describe('Notification', () => {
let service: NotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NotificationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -1,41 +0,0 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class NotificationService {
notifications = signal<Notification[]>([]);
show(notification: Notification) {
this.notifications.update(items => [
...items,
notification,
]);
}
remove(notification: Notification) {
this.notifications.update(items =>
items.filter(item => item !== notification)
);
}
success(message: string) {
this.show({ type: 'success', message});
}
error(message: string) {
this.show({ type: 'danger', message});
}
warn(message: string) {
this.show({ type: 'warning', message});
}
info(message: string) {
this.show({ type: 'info', message});
}
}
export interface Notification {
type: 'success' | 'warning' | 'info' | 'danger';
message: string;
delay?: number;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

52
package-lock.json generated
View File

@@ -4245,16 +4245,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4833,29 +4823,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt/node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/beasties": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz",
@@ -8206,17 +8173,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
@@ -11328,7 +11284,6 @@
"version": "1.0.0",
"license": "GPL-3.0",
"dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
@@ -11342,7 +11297,6 @@
"winston": "^3.19.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
@@ -11356,6 +11310,12 @@
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
},
"shared": {
"version": "1.0.0",
"extraneous": true,
"license": "GPL-3.0",
"devDependencies": {}
}
}
}

View File

@@ -15,13 +15,9 @@
"setup": "docker compose up -d",
"teardown": "docker compose down",
"keygen": "ts-node src/scripts/keygen.ts",
"hash": "ts-node src/scripts/hash.ts",
"verify-hash": "ts-node src/scripts/verify-hash.ts",
"create-user": "ts-node src/scripts/create-user.ts",
"dev": "npm run setup && npm run node; npm run teardown"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
@@ -35,7 +31,6 @@
"winston": "^3.19.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",

View File

@@ -1,13 +1,9 @@
import Account from "../model/user";
export class LoginRequest{
constructor(
public username: string,
public password: string
username: string,
password: string
){}
}
export class LoginResponse{
constructor(
public user: Account,
public ownedAccounts: Account[],
){}
constructor(){}
}

View File

@@ -1,5 +0,0 @@
export class GenericMessage{
constructor(
public message: string
){}
}

View File

@@ -1,6 +1,5 @@
export class SendRequest{
constructor(
public senderID: string,
public recipientID: string,
public amount: number,
public reference: string
@@ -10,5 +9,6 @@ export class SendRequest{
export class SendResponse{
constructor(
public balance: number,
public message: string
){}
}

View File

@@ -1,15 +0,0 @@
import Transaction from "../model/transaction";
export class TransactionsRequest{
constructor(
public forId: string,
public offset = 0,
public count = 50,
){}
}
export class TransactionsResponse{
constructor(
public transactions: Transaction[],
){}
}

View File

@@ -1,11 +0,0 @@
import Account from "../model/user";
export class UserRequest{
constructor(
){}
}
export class UserResponse{
constructor(
public user: Account
){}
}

View File

@@ -1,5 +1,5 @@
import { Table, Column, Model, CreatedAt, ForeignKey, BelongsTo} from 'sequelize-typescript';
import Account from './user';
import User from './user';
@Table
export default class Transaction extends Model{
@@ -10,18 +10,18 @@ export default class Transaction extends Model{
declare reference: string;
@Column
@ForeignKey(()=> Account)
@ForeignKey(()=> User)
declare senderID: string;
@BelongsTo(() => Account, 'senderID')
declare sender: Account;
@BelongsTo(() => User, 'senderID')
declare sender: User;
@Column
@ForeignKey(()=> Account)
@ForeignKey(()=> User)
declare receiverID: string;
@BelongsTo(() => Account, 'receiverID')
declare receiver: Account;
@BelongsTo(() => User, 'receiverID')
declare receiver: User;
@CreatedAt
declare date: Date;

View File

@@ -1,66 +1,26 @@
import { Table, Column, Model, DataType, Scopes, DefaultScope, DeletedAt, BelongsToMany, ForeignKey} from 'sequelize-typescript';
import { Table, Column, Model, CreatedAt, DataType, Scopes} from 'sequelize-typescript';
@Table
@DefaultScope(() => ({
//FIXME getting account still includes password
attributes:{ exclude: ['password']}
}))
@Scopes(() => ({
withPassword: {
attributes: {include: ['password']}
withoutPassword: {
attributes: { exclude: ['password'] }
}
}))
export default class Account extends Model{
@Column({primaryKey: true, unique: true, allowNull: false})
declare id: string;
@Column
declare isBusiness: boolean;
@Column
declare displayName: string;
@Column(DataType.DECIMAL(20,2))
declare balance: number;
@Column
declare password: string;
@DeletedAt
declare deletedAt: Date | null;
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownerAccountId', 'ownedAccountId')
declare ownedBusinesses: Account[];
@BelongsToMany(() => Account, () => BusinessOwnership, 'ownedAccountId', 'ownerAccountId')
declare owners: Account[];
override toJSON() {
const values = { ...this.get() };
delete values.password;
return values;
}
}
@Table
export class BusinessOwnership extends Model {
@ForeignKey(() => Account)
@Column
declare ownerAccountId: string;
export default class User extends Model{
@ForeignKey(() => Account)
@Column
declare ownedAccountId: string;
@Column({primaryKey: true, unique: true, allowNull: false})
declare userID: string;
@DeletedAt
declare deletedAt: Date | null;
}
@Column
declare displayName: string;
@Column(DataType.DECIMAL(20,2))
declare balance: number;
@Column
declare password: string;
@CreatedAt
declare creationDate: Date;
export async function getOwnedAccounts(user: Account){
let q = await Account.findByPk(user.id, {
include: ['ownedBusinesses'],
});
let ownedAccounts = q?.ownedBusinesses;
return ownedAccounts ?? [];
}

View File

@@ -1,34 +1,35 @@
import { compare } from 'bcrypt';
import express from 'express';
import { logger } from '../util/logging';
import Account, { getOwnedAccounts } from '../model/user';
import { Scope } from '../util/db';
import User from '../model/user';
import { getJWT, requireAuth } from '../util/auth';
import { LoginRequest, LoginResponse } from '../messages/Login';
import { GenericMessage as Msg } from '../messages/Message';
import { LoginRequest } from '@message/Login';
import { SendRequest, SendResponse } from '@message/Send';
const router = express.Router();
router.post('/login', async (req, res) => {
try {
const data : LoginRequest = req.body;
const user = await Account.scope(Scope.withPassword).findOne({where: { id: data.username}});
if (!user) return res.status(401).json(new Msg('Invalid credentials'));
const isMatch = await compare(data.password, user.password);
if (!isMatch) return res.status(401).json(new Msg('Invalid credentials'));
const { username, password } = req.body;
const user = await User.findOne({where: { userID: username}});
if (!user) return res.status(401).json({ message: 'Invalid credentials' });
const isMatch = (password == user.password);
//TODO hash passwords
//const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) return res.status(401).json({ message: 'Invalid credentials' });
// successfully authenticated
let jwt = await getJWT(user);
res.cookie('jwt', jwt, {
httpOnly: true, // Prevent XSS
secure: process.env.NODE_ENV === 'production', // HTTPS only
httpOnly: true, // Prevent XSS
secure: process.env.NODE_ENV === 'production',// HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 86400000, // 1 day
});
res.json(new LoginResponse(user, await getOwnedAccounts(user)));
res.json({ message: 'Logged in successfully' });
}catch (err) {
logger.error('Failed to authenticate:', err);
res.status(500).json(new Msg('Failed to authenticate'));
res.status(500).json({ message: 'Failed to authenticate' });
}
});

View File

@@ -1,39 +1,29 @@
import express from 'express';
import { logger } from '../util/logging';
import { requireAuth } from '../util/auth';
import Account, { BusinessOwnership, getOwnedAccounts } from '../model/user';
import User from '../model/user';
import { db } from '../util/db';
import Transaction from '../model/transaction';
import { SendRequest, SendResponse} from '../messages/Send';
import { GenericMessage as Err} from '../messages/Message';
const router = express.Router();
router.post('/', requireAuth, async (req, res) => {
try {
const user = res.locals.user as Account;
const sender = res.locals.user as User;
const data : SendRequest = req.body;
const recipient = await User.findOne({where: {userID: data.recipientID}})
if ( Number(data.amount) <= 0) {
return res.status(400).json(new Err('Invalid transfer amount'));
// TODO return SendResponse here and everywhere else in this file
return res.status(400).json({ error: 'Invalid transfer amount' });
}
const recipient = await Account.findOne({where: {id: data.recipientID}})
if (!recipient) {
return res.status(404).json(new Err('Recipient not found' ));
}
const sender = await Account.findOne({where: {id: data.senderID}})
if (!sender) {
return res.status(404).json(new Err('Sender not found' ));
}
let ownsAccount = (await getOwnedAccounts(user))?.some(
business => business.id == data.senderID
);
if(!(user.equals(sender) || ownsAccount )){
return res.status(403).json(new Err('Unauthorized sender'));
return res.status(404).json({ error: 'Recipient not found' });
}
if (Number(sender.balance) < Number(data.amount)){
logger.debug(`Insufficient balance: ${sender.balance} < ${data.amount}`)
return res.status(402).json(new Err('Insufficient balance'))
logger.error(`Insufficient balance: ${sender.balance} < ${data.amount}`)
return res.status(402).json({error: 'Insufficient balance'})
}
await db.transaction(async (t) =>{
@@ -41,15 +31,15 @@ router.post('/', requireAuth, async (req, res) => {
await recipient.increment({balance: data.amount});
await Transaction.create({
amount: data.amount,
senderID: sender.id,
receiverID: recipient.id,
senderID: sender.userID,
receiverID: recipient.userID,
reference: data.reference
});
})
return res.status(200).json(new SendResponse(sender.balance))
return res.status(200).json({balance: sender.balance, amount: data.amount});
} catch (err) {
logger.error('Failed to commit transaction:', err);
return res.status(500).json(new Err('Failed to commit transaction' ));
return res.status(500).json({ error: 'Failed to commit transaction' });
}
});

View File

@@ -1,12 +0,0 @@
import { db, testConnection } from "../util/db";
import { hash } from "bcrypt";
import Account from '../model/user';
(async () =>{
await testConnection();
await Account.create({
id: process.argv[2],
displayName: process.argv[3],
balance: process.argv[4],
password: await hash(process.argv[5], 10),
})
})();

View File

@@ -1,5 +0,0 @@
import {hash} from 'bcrypt';
let pass = process.argv[2];
hash(pass, 10, function(err, hash) {
console.log(hash);
});

View File

@@ -1,6 +0,0 @@
import {compare} from 'bcrypt';
let pass = process.argv[2];
let hash = process.argv[3];
compare(pass,hash, function(err, result){
console.log(result);
});

View File

@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express";
import Account from "../model/user"
import User from "../model/user"
import { importJWK, SignJWT, jwtVerify } from "jose";
@@ -9,9 +9,9 @@ async function setKeyFromEnv() {
key = await importJWK(JSON.parse(process.env.FM_PRIVATE_KEY));
}
async function getJWT(user: Account){
async function getJWT(user: User){
let jwt = await new SignJWT()
.setSubject(user.id)
.setSubject(user.userID)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.sign(key);
@@ -26,7 +26,7 @@ async function requireAuth(req: Request, res: Response, next: NextFunction) {
return res.status(401).json({ error: 'Unauthorized: No token provided' });
}
const jwt= await jwtVerify(token, key);
const user = await Account.findOne({where: { id: jwt.payload.sub}});
const user = await User.findOne({where: { userID: jwt.payload.sub}});
if (!user) {
return res.status(401).json({ error: 'Unauthorized: User not found' });
}

View File

@@ -1,13 +1,8 @@
import { Sequelize } from 'sequelize-typescript';
import { logger } from './logging';
import Account, { BusinessOwnership } from '../model/user';
import User from '../model/user';
import Transaction from '../model/transaction';
enum Scope{
// for User
withPassword = 'withPassword',
}
// Initialize Sequelize
const db = new Sequelize({
dialect: 'postgres',
@@ -19,7 +14,7 @@ const db = new Sequelize({
logging: logger.debug.bind(logger),
});
db.addModels([Account, BusinessOwnership, Transaction ])
db.addModels([User, Transaction ])
// Test the connection
async function testConnection() {
@@ -33,4 +28,4 @@ async function testConnection() {
}
// Export Sequelize instance and models
export { logger, db, testConnection, Scope };
export { logger, db, testConnection };