Merge branch 'master' into login_call_resetPassword

# Conflicts:
#	backend/src/graphql/resolver/UserResolver.ts
This commit is contained in:
Ulf Gebhardt 2021-11-26 02:36:32 +01:00
commit 5cccd8f873
Signed by: ulfgebhardt
GPG Key ID: DA6B843E748679C9
23 changed files with 223 additions and 128 deletions

View File

@ -0,0 +1,5 @@
import { JwtPayload } from 'jsonwebtoken'
export interface CustomJwtPayload extends JwtPayload {
pubKey: Buffer
}

View File

@ -0,0 +1,13 @@
import { RIGHTS } from './RIGHTS'
export const INALIENABLE_RIGHTS = [
RIGHTS.LOGIN,
RIGHTS.GET_COMMUNITY_INFO,
RIGHTS.COMMUNITIES,
RIGHTS.LOGIN_VIA_EMAIL_VERIFICATION_CODE,
RIGHTS.CREATE_USER,
RIGHTS.SEND_RESET_PASSWORD_EMAIL,
RIGHTS.RESET_PASSWORD,
RIGHTS.CHECK_USERNAME,
RIGHTS.CHECK_EMAIL,
]

19
backend/src/auth/JWT.ts Normal file
View File

@ -0,0 +1,19 @@
import jwt from 'jsonwebtoken'
import CONFIG from '../config/'
import { CustomJwtPayload } from './CustomJwtPayload'
export const decode = (token: string): CustomJwtPayload | null => {
if (!token) throw new Error('401 Unauthorized')
try {
return <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET)
} catch (err) {
return null
}
}
export const encode = (pubKey: Buffer): string => {
const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
})
return token
}

View File

@ -0,0 +1,26 @@
export enum RIGHTS {
LOGIN = 'LOGIN',
VERIFY_LOGIN = 'VERIFY_LOGIN',
BALANCE = 'BALANCE',
GET_COMMUNITY_INFO = 'GET_COMMUNITY_INFO',
COMMUNITIES = 'COMMUNITIES',
LIST_GDT_ENTRIES = 'LIST_GDT_ENTRIES',
EXIST_PID = 'EXIST_PID',
GET_KLICKTIPP_USER = 'GET_KLICKTIPP_USER',
GET_KLICKTIPP_TAG_MAP = 'GET_KLICKTIPP_TAG_MAP',
UNSUBSCRIBE_NEWSLETTER = 'UNSUBSCRIBE_NEWSLETTER',
SUBSCRIBE_NEWSLETTER = 'SUBSCRIBE_NEWSLETTER',
TRANSACTION_LIST = 'TRANSACTION_LIST',
SEND_COINS = 'SEND_COINS',
LOGIN_VIA_EMAIL_VERIFICATION_CODE = 'LOGIN_VIA_EMAIL_VERIFICATION_CODE',
LOGOUT = 'LOGOUT',
CREATE_USER = 'CREATE_USER',
SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL',
RESET_PASSWORD = 'RESET_PASSWORD',
UPDATE_USER_INFOS = 'UPDATE_USER_INFOS',
CHECK_USERNAME = 'CHECK_USERNAME',
CHECK_EMAIL = 'CHECK_EMAIL',
HAS_ELOPAGE = 'HAS_ELOPAGE',
// Admin
SEARCH_USERS = 'SEARCH_USERS',
}

25
backend/src/auth/ROLES.ts Normal file
View File

@ -0,0 +1,25 @@
import { INALIENABLE_RIGHTS } from './INALIENABLE_RIGHTS'
import { RIGHTS } from './RIGHTS'
import { Role } from './Role'
export const ROLE_UNAUTHORIZED = new Role('unauthorized', INALIENABLE_RIGHTS)
export const ROLE_USER = new Role('user', [
...INALIENABLE_RIGHTS,
RIGHTS.VERIFY_LOGIN,
RIGHTS.BALANCE,
RIGHTS.LIST_GDT_ENTRIES,
RIGHTS.EXIST_PID,
RIGHTS.GET_KLICKTIPP_USER,
RIGHTS.GET_KLICKTIPP_TAG_MAP,
RIGHTS.UNSUBSCRIBE_NEWSLETTER,
RIGHTS.SUBSCRIBE_NEWSLETTER,
RIGHTS.TRANSACTION_LIST,
RIGHTS.SEND_COINS,
RIGHTS.LOGOUT,
RIGHTS.UPDATE_USER_INFOS,
RIGHTS.HAS_ELOPAGE,
])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights
// TODO from database
export const ROLES = [ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN]

15
backend/src/auth/Role.ts Normal file
View File

@ -0,0 +1,15 @@
import { RIGHTS } from './RIGHTS'
export class Role {
id: string
rights: RIGHTS[]
constructor(id: string, rights: RIGHTS[]) {
this.id = id
this.rights = rights
}
hasRight = (right: RIGHTS): boolean => {
return this.rights.includes(right)
}
}

View File

@ -11,6 +11,9 @@ export default class CreateUserArgs {
@Field(() => String)
lastName: string
@Field(() => String)
password: string
@Field(() => String)
language?: string // Will default to DEFAULT_LANGUAGE

View File

@ -2,19 +2,44 @@
import { AuthChecker } from 'type-graphql'
import decode from '../../jwt/decode'
import encode from '../../jwt/encode'
import { decode, encode } from '../../auth/JWT'
import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '../../auth/ROLES'
import { RIGHTS } from '../../auth/RIGHTS'
import { ServerUserRepository } from '../../typeorm/repository/ServerUser'
import { getCustomRepository } from 'typeorm'
import { UserRepository } from '../../typeorm/repository/User'
const isAuthorized: AuthChecker<any> = async (
{ /* root, args, */ context /*, info */ } /*, roles */,
) => {
const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
context.role = ROLE_UNAUTHORIZED // unauthorized user
// Do we have a token?
if (context.token) {
const decoded = decode(context.token)
if (!decoded) {
// we always throw on an invalid token
throw new Error('403.13 - Client certificate revoked')
}
// Set context pubKey
context.pubKey = Buffer.from(decoded.pubKey).toString('hex')
// set new header token
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
const userRepository = await getCustomRepository(UserRepository)
const user = await userRepository.findByPubkeyHex(context.pubKey)
const serverUserRepository = await getCustomRepository(ServerUserRepository)
const countServerUsers = await serverUserRepository.count({ email: user.email })
context.role = countServerUsers > 0 ? ROLE_ADMIN : ROLE_USER
context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) })
return true
}
throw new Error('401 Unauthorized')
// check for correct rights
const missingRights = (<RIGHTS[]>rights).filter((right) => !context.role.hasRight(right))
if (missingRights.length !== 0) {
throw new Error('401 Unauthorized')
}
return true
}
export default isAuthorized

View File

@ -1,10 +1,12 @@
import { Resolver, Query, Arg } from 'type-graphql'
import { Resolver, Query, Arg, Authorized } from 'type-graphql'
import { getCustomRepository } from 'typeorm'
import { UserAdmin } from '../model/UserAdmin'
import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
import { RIGHTS } from '../../auth/RIGHTS'
@Resolver()
export class AdminResolver {
@Authorized([RIGHTS.SEARCH_USERS])
@Query(() => [UserAdmin])
async searchUsers(@Arg('searchText') searchText: string): Promise<UserAdmin[]> {
const loginUserRepository = getCustomRepository(LoginUserRepository)

View File

@ -8,10 +8,11 @@ import { BalanceRepository } from '../../typeorm/repository/Balance'
import { UserRepository } from '../../typeorm/repository/User'
import { calculateDecay } from '../../util/decay'
import { roundFloorFrom4 } from '../../util/round'
import { RIGHTS } from '../../auth/RIGHTS'
@Resolver()
export class BalanceResolver {
@Authorized()
@Authorized([RIGHTS.BALANCE])
@Query(() => Balance)
async balance(@Ctx() context: any): Promise<Balance> {
// load user and balance

View File

@ -1,12 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Resolver, Query } from 'type-graphql'
import { Resolver, Query, Authorized } from 'type-graphql'
import { RIGHTS } from '../../auth/RIGHTS'
import CONFIG from '../../config'
import { Community } from '../model/Community'
@Resolver()
export class CommunityResolver {
@Authorized([RIGHTS.GET_COMMUNITY_INFO])
@Query(() => Community)
async getCommunityInfo(): Promise<Community> {
return new Community({
@ -17,6 +19,7 @@ export class CommunityResolver {
})
}
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => [Community])
async communities(): Promise<Community[]> {
if (CONFIG.PRODUCTION)

View File

@ -9,10 +9,11 @@ import Paginated from '../arg/Paginated'
import { apiGet } from '../../apis/HttpRequest'
import { UserRepository } from '../../typeorm/repository/User'
import { Order } from '../enum/Order'
import { RIGHTS } from '../../auth/RIGHTS'
@Resolver()
export class GdtResolver {
@Authorized()
@Authorized([RIGHTS.LIST_GDT_ENTRIES])
@Query(() => GdtEntryList)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async listGDTEntries(
@ -33,7 +34,7 @@ export class GdtResolver {
return new GdtEntryList(resultGDT.data)
}
@Authorized()
@Authorized([RIGHTS.EXIST_PID])
@Query(() => Number)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async existPid(@Arg('pid') pid: number): Promise<number> {

View File

@ -8,29 +8,30 @@ import {
unsubscribe,
signIn,
} from '../../apis/KlicktippController'
import { RIGHTS } from '../../auth/RIGHTS'
import SubscribeNewsletterArgs from '../arg/SubscribeNewsletterArgs'
@Resolver()
export class KlicktippResolver {
@Authorized()
@Authorized([RIGHTS.GET_KLICKTIPP_USER])
@Query(() => String)
async getKlicktippUser(@Arg('email') email: string): Promise<string> {
return await getKlickTippUser(email)
}
@Authorized()
@Authorized([RIGHTS.GET_KLICKTIPP_TAG_MAP])
@Query(() => String)
async getKlicktippTagMap(): Promise<string> {
return await getKlicktippTagMap()
}
@Authorized()
@Authorized([RIGHTS.UNSUBSCRIBE_NEWSLETTER])
@Mutation(() => Boolean)
async unsubscribeNewsletter(@Arg('email') email: string): Promise<boolean> {
return await unsubscribe(email)
}
@Authorized()
@Authorized([RIGHTS.SUBSCRIBE_NEWSLETTER])
@Mutation(() => Boolean)
async subscribeNewsletter(
@Args() { email, language }: SubscribeNewsletterArgs,

View File

@ -34,6 +34,7 @@ import { TransactionTypeId } from '../enum/TransactionTypeId'
import { TransactionType } from '../enum/TransactionType'
import { hasUserAmount, isHexPublicKey } from '../../util/validate'
import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
import { RIGHTS } from '../../auth/RIGHTS'
/*
# Test
@ -465,7 +466,7 @@ async function getPublicKey(email: string): Promise<string | null> {
@Resolver()
export class TransactionResolver {
@Authorized()
@Authorized([RIGHTS.TRANSACTION_LIST])
@Query(() => TransactionList)
async transactionList(
@Args() { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@ -499,7 +500,7 @@ export class TransactionResolver {
return transactions
}
@Authorized()
@Authorized([RIGHTS.SEND_COINS])
@Mutation(() => String)
async sendCoins(
@Args() { email, amount, memo }: TransactionSendArgs,

View File

@ -7,7 +7,8 @@ import { getConnection, getCustomRepository, getRepository } from 'typeorm'
import CONFIG from '../../config'
import { User } from '../model/User'
import { User as DbUser } from '@entity/User'
import encode from '../../jwt/encode'
import { encode } from '../../auth/JWT'
import ChangePasswordArgs from '../arg/ChangePasswordArgs'
import CheckUsernameArgs from '../arg/CheckUsernameArgs'
import CreateUserArgs from '../arg/CreateUserArgs'
import UnsecureLoginArgs from '../arg/UnsecureLoginArgs'
@ -23,6 +24,9 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendEMail } from '../../util/sendEMail'
import { LoginElopageBuysRepository } from '../../typeorm/repository/LoginElopageBuys'
import { signIn } from '../../apis/KlicktippController'
import { RIGHTS } from '../../auth/RIGHTS'
import { ServerUserRepository } from '../../typeorm/repository/ServerUser'
import { ROLE_ADMIN } from '../../auth/ROLES'
const EMAIL_OPT_IN_RESET_PASSWORD = 2
const EMAIL_OPT_IN_REGISTER = 1
@ -148,37 +152,7 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
@Resolver()
export class UserResolver {
/*
@Authorized()
@Query(() => User)
async verifyLogin(@Ctx() context: any): Promise<User> {
const loginUserRepository = getCustomRepository(LoginUserRepository)
loginUser = loginUserRepository.findByPubkeyHex()
const user = new User(result.data.user)
this.email = json.email
this.firstName = json.first_name
this.lastName = json.last_name
this.username = json.username
this.description = json.description
this.pubkey = json.public_hex
this.language = json.language
this.publisherId = json.publisher_id
this.isAdmin = json.isAdmin
const userSettingRepository = getCustomRepository(UserSettingRepository)
const coinanimation = await userSettingRepository
.readBoolean(userEntity.id, Setting.COIN_ANIMATION)
.catch((error) => {
throw new Error(error)
})
user.coinanimation = coinanimation
user.isAdmin = true // TODO implement
return user
}
*/
@Authorized()
@Authorized([RIGHTS.VERIFY_LOGIN])
@Query(() => User)
@UseMiddleware(klicktippNewsletterStateMiddleware)
async verifyLogin(@Ctx() context: any): Promise<User> {
@ -207,10 +181,12 @@ export class UserResolver {
throw new Error(error)
})
user.coinanimation = coinanimation
user.isAdmin = true // TODO implement
user.isAdmin = context.role === ROLE_ADMIN
return user
}
@Authorized([RIGHTS.LOGIN])
@Query(() => User)
@UseMiddleware(klicktippNewsletterStateMiddleware)
async login(
@ -292,7 +268,11 @@ export class UserResolver {
throw new Error(error)
})
user.coinanimation = coinanimation
user.isAdmin = true // TODO implement
// context.role is not set to the actual role yet on login
const serverUserRepository = await getCustomRepository(ServerUserRepository)
const countServerUsers = await serverUserRepository.count({ email: user.email })
user.isAdmin = countServerUsers > 0
context.setHeaders.push({
key: 'token',
@ -302,7 +282,7 @@ export class UserResolver {
return user
}
@Authorized()
@Authorized([RIGHTS.LOGOUT])
@Query(() => String)
async logout(): Promise<boolean> {
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
@ -313,6 +293,7 @@ export class UserResolver {
return true
}
@Authorized([RIGHTS.CREATE_USER])
@Mutation(() => String)
async createUser(
@Args() { email, firstName, lastName, language, publisherId }: CreateUserArgs,
@ -342,6 +323,9 @@ export class UserResolver {
}
const passphrase = PassphraseGenerate()
// const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
// const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
const emailHash = getEmailHash(email)
// Table: login_users
@ -388,7 +372,7 @@ export class UserResolver {
dbUser.lastName = lastName
dbUser.username = username
// TODO this field has no null allowed unlike the loginServer table
dbUser.pubkey = Buffer.alloc(32, 0) // defualt to 0000...
dbUser.pubkey = Buffer.alloc(32, 0) // default to 0000...
// dbUser.pubkey = keyPair[0]
await queryRunner.manager.save(dbUser).catch((er) => {
@ -446,6 +430,7 @@ export class UserResolver {
return 'success'
}
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
@Query(() => Boolean)
async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> {
// TODO: this has duplicate code with createUser
@ -504,6 +489,7 @@ export class UserResolver {
return true
}
@Authorized([RIGHTS.SET_PASSWORD])
@Mutation(() => Boolean)
async setPassword(
@Arg('code') code: string,
@ -612,7 +598,7 @@ export class UserResolver {
return true
}
@Authorized()
@Authorized([RIGHTS.UPDATE_USER_INFOS])
@Mutation(() => Boolean)
async updateUserInfos(
@Args()
@ -721,6 +707,7 @@ export class UserResolver {
return true
}
@Authorized([RIGHTS.CHECK_USERNAME])
@Query(() => Boolean)
async checkUsername(@Args() { username }: CheckUsernameArgs): Promise<boolean> {
// Username empty?
@ -744,7 +731,7 @@ export class UserResolver {
return true
}
@Authorized()
@Authorized([RIGHTS.HAS_ELOPAGE])
@Query(() => Boolean)
async hasElopage(@Ctx() context: any): Promise<boolean> {
const userRepository = getCustomRepository(UserRepository)

View File

@ -1,26 +0,0 @@
import jwt, { JwtPayload } from 'jsonwebtoken'
import CONFIG from '../config/'
interface CustomJwtPayload extends JwtPayload {
pubKey: Buffer
}
type DecodedJwt = {
token: string
pubKey: Buffer
}
export default (token: string): DecodedJwt => {
if (!token) throw new Error('401 Unauthorized')
let pubKey = null
try {
const decoded = <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET)
pubKey = decoded.pubKey
return {
token,
pubKey,
}
} catch (err) {
throw new Error('403.13 - Client certificate revoked')
}
}

View File

@ -1,13 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import jwt from 'jsonwebtoken'
import CONFIG from '../config/'
// Generate an Access Token
export default function encode(pubKey: Buffer): string {
const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
})
return token
}

View File

@ -0,0 +1,5 @@
import { EntityRepository, Repository } from 'typeorm'
import { ServerUser } from '@entity/ServerUser'
@EntityRepository(ServerUser)
export class ServerUserRepository extends Repository<ServerUser> {}

View File

@ -27,7 +27,6 @@
import { LoginElopageBuys } from '@entity/LoginElopageBuys'
import { LoginUser } from '@entity/LoginUser'
import { randomBytes } from 'crypto'
import { UserResolver } from '../graphql/resolver/UserResolver'
export const elopageWebhook = async (req: any, res: any): Promise<void> => {
@ -145,6 +144,7 @@ export const elopageWebhook = async (req: any, res: any): Promise<void> => {
firstName,
lastName,
publisherId: loginElopgaeBuy.publisherId,
password: '123', // TODO remove
})
} catch (error) {
// eslint-disable-next-line no-console

View File

@ -0,0 +1,31 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
@Entity('server_users')
export class ServerUser extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ length: 50 })
username: string
@Column({ type: 'bigint', unsigned: true })
password: BigInt
@Column({ length: 50, unique: true })
email: string
@Column({ length: 20, default: 'admin' })
role: string
@Column({ default: 0 })
activated: number
@Column({ name: 'last_login', default: null, nullable: true })
lastLogin: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
created: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
modified: Date
}

View File

@ -1,31 +1 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
@Entity('server_users')
export class ServerUser extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ length: 50 })
username: string
@Column({ type: 'bigint', unsigned: true })
password: BigInt
@Column({ length: 50, unique: true })
email: string
@Column({ length: 20, default: 'admin' })
role: string
@Column({ default: 0 })
activated: number
@Column({ name: 'last_login', default: null, nullable: true })
lastLogin: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
created: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
modified: Date
}
export { ServerUser } from './0001-init_db/ServerUser'

View File

@ -4,6 +4,7 @@ import { LoginEmailOptIn } from './LoginEmailOptIn'
import { LoginUser } from './LoginUser'
import { LoginUserBackup } from './LoginUserBackup'
import { Migration } from './Migration'
import { ServerUser } from './ServerUser'
import { Transaction } from './Transaction'
import { TransactionCreation } from './TransactionCreation'
import { TransactionSendCoin } from './TransactionSendCoin'
@ -18,6 +19,7 @@ export const entities = [
LoginUser,
LoginUserBackup,
Migration,
ServerUser,
Transaction,
TransactionCreation,
TransactionSendCoin,

View File

@ -14,7 +14,6 @@ const addNavigationGuards = (router, store, apollo) => {
// store token on authenticate
router.beforeEach(async (to, from, next) => {
if (to.path === '/authenticate' && to.query.token) {
// TODO verify user in order to get user data
store.commit('token', to.query.token)
const result = await apollo.query({
query: verifyLogin,