feat(oauth2/db): reworked oauth2 database footprint
- Removed `auth` table and merged its information inside the `user` table - Changed around some field names in the database - Changed Create*User functions to not be using overload but different functions
This commit is contained in:
parent
34249bf68d
commit
5a905a1239
10 changed files with 90 additions and 142 deletions
|
|
@ -3,14 +3,12 @@ import { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
import { isNullish } from '@shared/utils';
|
import { isNullish } from '@shared/utils';
|
||||||
import { Database as DbImpl } from './mixin/_base';
|
import { Database as DbImpl } from './mixin/_base';
|
||||||
import { IOauthDb, OauthImpl } from './mixin/oauth2';
|
|
||||||
import { IUserDb, UserImpl } from './mixin/user';
|
import { IUserDb, UserImpl } from './mixin/user';
|
||||||
|
|
||||||
|
|
||||||
Object.assign(DbImpl.prototype, UserImpl);
|
Object.assign(DbImpl.prototype, UserImpl);
|
||||||
Object.assign(DbImpl.prototype, OauthImpl);
|
|
||||||
|
|
||||||
export interface Database extends DbImpl, IUserDb, IOauthDb { }
|
export interface Database extends DbImpl, IUserDb { }
|
||||||
|
|
||||||
// When using .decorate you have to specify added properties for Typescript
|
// When using .decorate you have to specify added properties for Typescript
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,11 @@ Project Transcendance {
|
||||||
|
|
||||||
Table user {
|
Table user {
|
||||||
id text [PK, not null]
|
id text [PK, not null]
|
||||||
login_name text [unique]
|
login text [unique]
|
||||||
display_name text [not null]
|
name text [not null]
|
||||||
password text [null, Note: "If password is NULL, this means that the user is created through OAUTH2"]
|
password text [null, Note: "If password is NULL, this means that the user is created through OAUTH2 or guest login"]
|
||||||
otp text [null, Note: "If otp is NULL, then the user didn't configure 2FA"]
|
otp text [null, Note: "If otp is NULL, then the user didn't configure 2FA"]
|
||||||
guest integer [not null, default: 0]
|
guest integer [not null, default: 0]
|
||||||
|
oauth2 text [null, default: `NULL` , Note: "format: <provider>:<unique_id>; null if not logged via provider"]
|
||||||
Note: "Represent a user"
|
Note: "Represent a user"
|
||||||
}
|
}
|
||||||
|
|
||||||
Table auth {
|
|
||||||
id integer [PK, not null, increment]
|
|
||||||
provider text [not null]
|
|
||||||
user text [ref: > user.id, not null]
|
|
||||||
oauth2_user text [not null, unique, Note: '''
|
|
||||||
This makes sure that an oauth2 login is the always the same `user`
|
|
||||||
Aka can't have two account bound to the same <OAUTH2> account
|
|
||||||
''']
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
CREATE TABLE IF NOT EXISTS user (
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
login_name TEXT UNIQUE,
|
login TEXT UNIQUE,
|
||||||
display_name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
otp TEXT,
|
otp TEXT,
|
||||||
guest INTEGER NOT NULL DEFAULT 0
|
guest INTEGER NOT NULL DEFAULT 0,
|
||||||
);
|
oauth2 TEXT DEFAULT NULL
|
||||||
CREATE TABLE IF NOT EXISTS auth (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
user TEXT NOT NULL,
|
|
||||||
oauth2_user TEXT NOT NULL UNIQUE,
|
|
||||||
FOREIGN KEY(user) REFERENCES user(id)
|
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
// import type { Database } from './_base';
|
|
||||||
import { isNullish } from '@shared/utils';
|
|
||||||
import { UserId, IUserDb, userFromRow, User } from './user';
|
|
||||||
|
|
||||||
// never use this directly
|
|
||||||
|
|
||||||
export interface IOauthDb extends IUserDb {
|
|
||||||
getAllFromProvider(this: IOauthDb, provider: string): ProviderUser[],
|
|
||||||
getProviderUser(this: IOauthDb, provider: string, unique_id: string): ProviderUser | undefined,
|
|
||||||
getUserFromProviderUser(this: IOauthDb, provider: string, unique_id: string): User | undefined,
|
|
||||||
getUserFromProviderUserId(this: IOauthDb, id: ProviderUserId): User | undefined,
|
|
||||||
getProviderUserFromId(this: IOauthDb, id: ProviderUserId): ProviderUser | undefined,
|
|
||||||
|
|
||||||
createUserWithProvider(this: IOauthDb, provider: string, unique_id: string, username: string): Promise<User | undefined>,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OauthImpl: Omit<IOauthDb, keyof IUserDb> = {
|
|
||||||
getAllFromProvider(this: IOauthDb, provider: string): ProviderUser[] {
|
|
||||||
return this.prepare('SELECT * FROM auth WHERE provider = @provider')
|
|
||||||
.all({ provider })
|
|
||||||
.map(r => providerUserFromRow(r as Partial<ProviderUser> | undefined))
|
|
||||||
.filter(v => !isNullish(v)) ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
getProviderUser(this: IOauthDb, provider: string, unique_id: string): ProviderUser | undefined {
|
|
||||||
unique_id = `provider:${unique_id}`;
|
|
||||||
return providerUserFromRow(this.prepare('SELECT * FROM auth WHERE provider = @provider AND oauth2_user = @unique_id')
|
|
||||||
.get({ provider, unique_id }) as Partial<ProviderUser> | undefined);
|
|
||||||
},
|
|
||||||
|
|
||||||
getProviderUserFromId(this: IOauthDb, id: ProviderUserId): ProviderUser | undefined {
|
|
||||||
return providerUserFromRow(this.prepare('SELECT * FROM auth WHERE id = @id')
|
|
||||||
.get({ id }) as Partial<ProviderUser> | undefined);
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserFromProviderUser(this: IOauthDb, provider: string, unique_id: string): User | undefined {
|
|
||||||
unique_id = `provider:${unique_id}`;
|
|
||||||
return userFromRow(
|
|
||||||
this.prepare('SELECT user.* from auth INNER JOIN user ON user.id = auth.user WHERE auth.provider = @provider AND auth.oauth2_user = @unique_id')
|
|
||||||
.get({ provider, unique_id }) as Partial<User> | undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserFromProviderUserId(this: IOauthDb, id: ProviderUserId): User | undefined {
|
|
||||||
return userFromRow(
|
|
||||||
this.prepare('SELECT user.* from auth INNER JOIN user ON user.id = auth.user WHERE auth.id = @id')
|
|
||||||
.get({ id }) as Partial<User> | undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createUserWithProvider(this: IOauthDb, provider: string, unique_id: string, username: string): Promise<User | undefined> {
|
|
||||||
unique_id = `provider:${unique_id}`;
|
|
||||||
const user = await this.createUser(null, username, undefined, false);
|
|
||||||
if (isNullish(user)) { return undefined; }
|
|
||||||
this.prepare('INSERT INTO auth (provider, user, oauth2_user) VALUES (@provider, @user_id, @unique_id)').run({ provider, user_id: user.id, unique_id });
|
|
||||||
return user;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProviderUserId = number & { readonly __brand: unique symbol };
|
|
||||||
|
|
||||||
export type ProviderUser = {
|
|
||||||
readonly id: ProviderUserId,
|
|
||||||
readonly provider: string,
|
|
||||||
readonly user: UserId,
|
|
||||||
readonly oauth2_user: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a user from a row
|
|
||||||
*
|
|
||||||
* @param row The data from sqlite
|
|
||||||
*
|
|
||||||
* @returns The user if it exists, undefined otherwise
|
|
||||||
*/
|
|
||||||
function providerUserFromRow(row?: Partial<ProviderUser>): ProviderUser | undefined {
|
|
||||||
if (isNullish(row)) return undefined;
|
|
||||||
if (isNullish(row.id)) return undefined;
|
|
||||||
if (isNullish(row.provider)) return undefined;
|
|
||||||
if (isNullish(row.user)) return undefined;
|
|
||||||
if (isNullish(row.oauth2_user)) return undefined;
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
provider: row.provider,
|
|
||||||
user: row.user,
|
|
||||||
oauth2_user: row.oauth2_user,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -9,12 +9,15 @@ import { UUID, newUUID } from '@shared/utils/uuid';
|
||||||
export interface IUserDb extends Database {
|
export interface IUserDb extends Database {
|
||||||
getUserFromLoginName(name: string): User | undefined,
|
getUserFromLoginName(name: string): User | undefined,
|
||||||
getUser(id: string): User | undefined,
|
getUser(id: string): User | undefined,
|
||||||
|
getOauth2User(provider: string, unique: string): User | undefined,
|
||||||
getUserOtpSecret(id: UserId): string | undefined,
|
getUserOtpSecret(id: UserId): string | undefined,
|
||||||
createUser(login_name: string | null, display_name: string, password: string | undefined, guest: boolean): Promise<User | undefined>,
|
createUser(login: string | null, name: string, password: string): Promise<User | undefined>,
|
||||||
createUser(login_name: string | null, display_name: string, password: string | undefined): Promise<User | undefined>,
|
createGuestUser(name: string): Promise<User | undefined>,
|
||||||
|
createOauth2User(name: string, provider: string, provider_unique: string): Promise<User | undefined>,
|
||||||
setUserPassword(id: UserId, password: string | undefined): Promise<User | undefined>,
|
setUserPassword(id: UserId, password: string | undefined): Promise<User | undefined>,
|
||||||
ensureUserOtpSecret(id: UserId): string | undefined,
|
ensureUserOtpSecret(id: UserId): string | undefined,
|
||||||
deleteUserOtpSecret(id: UserId): void,
|
deleteUserOtpSecret(id: UserId): void,
|
||||||
|
getAllUserFromProvider(provider: string): User[] | undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserImpl: Omit<IUserDb, keyof Database> = {
|
export const UserImpl: Omit<IUserDb, keyof Database> = {
|
||||||
|
|
@ -28,7 +31,7 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
|
||||||
getUserFromLoginName(this: IUserDb, name: string): User | undefined {
|
getUserFromLoginName(this: IUserDb, name: string): User | undefined {
|
||||||
return userFromRow(
|
return userFromRow(
|
||||||
this.prepare(
|
this.prepare(
|
||||||
'SELECT * FROM user WHERE login_name = @name LIMIT 1',
|
'SELECT * FROM user WHERE login = @name LIMIT 1',
|
||||||
).get({ name }) as (Partial<User> | undefined),
|
).get({ name }) as (Partial<User> | undefined),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -56,13 +59,47 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
|
||||||
*
|
*
|
||||||
* @returns The user struct
|
* @returns The user struct
|
||||||
*/
|
*/
|
||||||
async createUser(this: IUserDb, login_name: string | null, display_name: string, password: string | undefined, guest: boolean = false): Promise<User | undefined> {
|
async createUser(this: IUserDb, login: string, name: string, password: string): Promise<User | undefined> {
|
||||||
password = await hashPassword(password);
|
const password_ = await hashPassword(password);
|
||||||
const id = newUUID();
|
const id = newUUID();
|
||||||
return userFromRow(
|
return userFromRow(
|
||||||
this.prepare(
|
this.prepare(
|
||||||
'INSERT OR FAIL INTO user (id, login_name, display_name, password, guest) VALUES (@id, @login_name, @display_name, @password, @guest) RETURNING *',
|
'INSERT OR FAIL INTO user (id, login, name, password, guest, oauth2) VALUES (@id, @login, @name, @password, @guest, @oauth2) RETURNING *',
|
||||||
).get({ id, login_name, display_name, password, guest: guest ? 1 : 0 }) as (Partial<User> | undefined),
|
).get({ id, login, name, password: password_, guest: 0, oauth2: null }) as (Partial<User> | undefined),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user using password hash
|
||||||
|
*
|
||||||
|
* @param name the username for the new user (must be unique and sanitized)
|
||||||
|
* @param password the plaintext password of the new user (if any)
|
||||||
|
*
|
||||||
|
* @returns The user struct
|
||||||
|
*/
|
||||||
|
async createGuestUser(this: IUserDb, name: string): Promise<User | undefined> {
|
||||||
|
const id = newUUID();
|
||||||
|
return userFromRow(
|
||||||
|
this.prepare(
|
||||||
|
'INSERT OR FAIL INTO user (id, login, name, password, guest, oauth2) VALUES (@id, @login, @name, @password, @guest, @oauth2) RETURNING *',
|
||||||
|
).get({ id, login: null, name, password: null, guest: 1, oauth2: null }) as (Partial<User> | undefined),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user using password hash
|
||||||
|
*
|
||||||
|
* @param name the username for the new user (must be unique and sanitized)
|
||||||
|
* @param password the plaintext password of the new user (if any)
|
||||||
|
*
|
||||||
|
* @returns The user struct
|
||||||
|
*/
|
||||||
|
async createOauth2User(this: IUserDb, name: string, provider: string, provider_unique: string): Promise<User | undefined> {
|
||||||
|
const id = newUUID();
|
||||||
|
return userFromRow(
|
||||||
|
this.prepare(
|
||||||
|
'INSERT OR FAIL INTO user (id, login, name, password, guest, oauth2) VALUES (@id, @login, @name, @password, @guest, @oauth2) RETURNING *',
|
||||||
|
).get({ id, login: null, name, password: null, guest: 0, oauth2: `${provider}:${provider_unique}` }) as (Partial<User> | undefined),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -102,17 +139,29 @@ export const UserImpl: Omit<IUserDb, keyof Database> = {
|
||||||
deleteUserOtpSecret(this: IUserDb, id: UserId): void {
|
deleteUserOtpSecret(this: IUserDb, id: UserId): void {
|
||||||
this.prepare('UPDATE OR IGNORE user SET otp = NULL WHERE id = @id').run({ id });
|
this.prepare('UPDATE OR IGNORE user SET otp = NULL WHERE id = @id').run({ id });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAllUserFromProvider(this: IUserDb, provider: string): User[] | undefined {
|
||||||
|
const req = this.prepare('SELECT * FROM user WHERE oauth2 LIKE oauth2 = @oauth2 || \'%\'');
|
||||||
|
return (req.all({ oauth2: provider }) as Partial<User>[]).map(userFromRow).filter(v => !isNullish(v));
|
||||||
|
},
|
||||||
|
getOauth2User(this: IUserDb, provider: string, unique: string): User | undefined {
|
||||||
|
const req = this.prepare('SELECT * FROM user WHERE oauth2 = @oauth2').get({ oauth2: `${provider}:${unique}` }) as Partial<User> | undefined;
|
||||||
|
return userFromRow(req);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserId = UUID;
|
export type UserId = UUID;
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
readonly id: UserId;
|
readonly id: UserId;
|
||||||
readonly login_name?: string;
|
readonly login?: string;
|
||||||
readonly display_name: string;
|
readonly name: string;
|
||||||
readonly password?: string;
|
readonly password?: string;
|
||||||
readonly otp?: string;
|
readonly otp?: string;
|
||||||
readonly guest: boolean;
|
readonly guest: boolean;
|
||||||
|
// will be split/merged from the `provider` column
|
||||||
|
readonly provider_name?: string;
|
||||||
|
readonly provider_unique?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function verifyUserPassword(
|
export async function verifyUserPassword(
|
||||||
|
|
@ -147,17 +196,29 @@ async function hashPassword(
|
||||||
*
|
*
|
||||||
* @returns The user if it exists, undefined otherwise
|
* @returns The user if it exists, undefined otherwise
|
||||||
*/
|
*/
|
||||||
export function userFromRow(row?: Partial<User>): User | undefined {
|
export function userFromRow(row?: Partial<Omit<User, 'provider_name' | 'provider_unique'> & { provider?: string }>): User | undefined {
|
||||||
if (isNullish(row)) return undefined;
|
if (isNullish(row)) return undefined;
|
||||||
if (isNullish(row.id)) return undefined;
|
if (isNullish(row.id)) return undefined;
|
||||||
if (isNullish(row.display_name)) return undefined;
|
if (isNullish(row.name)) return undefined;
|
||||||
if (isNullish(row.guest)) return undefined;
|
if (isNullish(row.guest)) return undefined;
|
||||||
|
|
||||||
|
let provider_name = undefined;
|
||||||
|
let provider_unique = undefined;
|
||||||
|
|
||||||
|
if (row.provider) {
|
||||||
|
const splitted = row.provider.split(':', 1);
|
||||||
|
if (splitted.length != 2) { return undefined; }
|
||||||
|
provider_name = splitted[0];
|
||||||
|
provider_unique = splitted[1];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
login_name: row.login_name ?? undefined,
|
login: row.login ?? undefined,
|
||||||
display_name: row.display_name,
|
name: row.name,
|
||||||
password: row.password ?? undefined,
|
password: row.password ?? undefined,
|
||||||
otp: row.otp ?? undefined,
|
otp: row.otp ?? undefined,
|
||||||
guest: !!(row.guest ?? true),
|
guest: !!(row.guest ?? true),
|
||||||
|
provider_name, provider_unique,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ function makeResponse<T extends object>(
|
||||||
key: string,
|
key: string,
|
||||||
payload?: T,
|
payload?: T,
|
||||||
): ReturnType<FastifyReply['send']> {
|
): ReturnType<FastifyReply['send']> {
|
||||||
|
this.log.info(`Sending response: ${status}; response = ${JSON.stringify({ kind, msg: key, payload })}`);
|
||||||
return this.code(status).send({ kind, msg: key, payload });
|
return this.code(status).send({ kind, msg: key, payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
||||||
const adjective = getRandomFromList(fastify.words.adjectives);
|
const adjective = getRandomFromList(fastify.words.adjectives);
|
||||||
const noun = getRandomFromList(fastify.words.nouns);
|
const noun = getRandomFromList(fastify.words.nouns);
|
||||||
|
|
||||||
const user = await this.db.createUser(
|
const user = await this.db.createGuestUser(`${adjective} ${noun}`);
|
||||||
// no login_name => can't login
|
|
||||||
null,
|
|
||||||
`${adjective} ${noun}`,
|
|
||||||
// no password
|
|
||||||
undefined,
|
|
||||||
// is a guest
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (isNullish(user)) {
|
if (isNullish(user)) {
|
||||||
return res.makeResponse(500, 'failed', 'guestLogin.failed.generic.unknown');
|
return res.makeResponse(500, 'failed', 'guestLogin.failed.generic.unknown');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
||||||
const result = await creq.getCode();
|
const result = await creq.getCode();
|
||||||
|
|
||||||
const userinfo = await provider.getUserInfo(result);
|
const userinfo = await provider.getUserInfo(result);
|
||||||
let u = this.db.getUserFromProviderUser(provider.display_name, userinfo.unique_id);
|
let u = this.db.getOauth2User(provider.display_name, userinfo.unique_id);
|
||||||
if (isNullish(u)) {
|
if (isNullish(u)) {
|
||||||
u = await this.db.createUserWithProvider(provider.display_name, userinfo.unique_id, userinfo.name);
|
u = await this.db.createOauth2User(userinfo.name, provider.display_name, userinfo.unique_id);
|
||||||
}
|
}
|
||||||
if (isNullish(u)) {
|
if (isNullish(u)) {
|
||||||
return res.code(500).send('failed to fetch or create user...');
|
return res.code(500).send('failed to fetch or create user...');
|
||||||
|
|
@ -40,7 +40,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
||||||
const token = this.signJwt('auth', u.id);
|
const token = this.signJwt('auth', u.id);
|
||||||
|
|
||||||
|
|
||||||
return res.setCookie('token', token, { path: '/' }).redirect('/');
|
return res.setCookie('token', token, { path: '/' }).redirect('/app/');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
||||||
// password is good too !
|
// password is good too !
|
||||||
|
|
||||||
if (this.db.getUserFromLoginName(name) !== undefined) { return res.makeResponse(400, 'failed', 'signin.failed.username.existing'); }
|
if (this.db.getUserFromLoginName(name) !== undefined) { return res.makeResponse(400, 'failed', 'signin.failed.username.existing'); }
|
||||||
const u = await this.db.createUser(name, name, password, false);
|
const u = await this.db.createUser(name, name, password);
|
||||||
if (isNullish(u)) { return res.makeResponse(500, 'failed', 'signin.failed.generic'); }
|
if (isNullish(u)) { return res.makeResponse(500, 'failed', 'signin.failed.generic'); }
|
||||||
|
|
||||||
// every check has been passed, they are now logged in, using this token to say who they are...
|
// every check has been passed, they are now logged in, using this token to say who they are...
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
||||||
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: user.display_name,
|
name: user.name,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
// the !! converts a value from <something> to either `true` or `false`
|
// the !! converts a value from <something> to either `true` or `false`
|
||||||
// it uses the same convention from using <something> in a if, meaning that
|
// it uses the same convention from using <something> in a if, meaning that
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue