mirror of
https://github.com/wassname/talk.git
synced 2026-07-03 18:58:39 +08:00
feat: signup enhancements; more extensions to schema
This commit is contained in:
@@ -1,8 +1,84 @@
|
||||
import { RequestHandler } from "express";
|
||||
import Joi from "joi";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { handle } from "talk-server/app/middleware/passport";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { LocalProfile } from "talk-server/models/user";
|
||||
import { create } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export const signup: RequestHandler = async (req: Request, res, next) => {
|
||||
// TODO: implement
|
||||
res.send("ok");
|
||||
export interface SignupBody {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const SignupBodySchema = Joi.object().keys({
|
||||
username: Joi.string().trim(),
|
||||
password: Joi.string().trim(),
|
||||
email: Joi.string().trim(),
|
||||
});
|
||||
|
||||
// Extends the default signup body schema with the displayName to allow it to be
|
||||
// sent.
|
||||
const SignupDisplayNameBodySchema = SignupBodySchema.keys({
|
||||
displayName: Joi.string().trim(),
|
||||
});
|
||||
|
||||
export interface SignupOptions {
|
||||
db: Db;
|
||||
}
|
||||
|
||||
export const signup = ({ db }: SignupOptions): RequestHandler => async (
|
||||
req: Request,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
try {
|
||||
// TODO: rate limit based on the IP address and user agent.
|
||||
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.tenant!;
|
||||
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
// TODO: replace with better error.
|
||||
return next(new Error("integration is disabled"));
|
||||
}
|
||||
|
||||
// Get the fields from the body. We condition on the display name being
|
||||
// enabled to allow the display name to be stripped in the event that the
|
||||
// display name is not enabled, yielding a displayName being `undefined`,
|
||||
// which will not be set in the resultant document. Validate will throw an
|
||||
// error if the body does not conform to the specification.
|
||||
const { username, password, email, displayName }: SignupBody = validate(
|
||||
tenant.auth.displayNameEnable
|
||||
? SignupDisplayNameBodySchema
|
||||
: SignupBodySchema,
|
||||
req.body
|
||||
);
|
||||
|
||||
// Configure with profile.
|
||||
const profile: LocalProfile = {
|
||||
id: email,
|
||||
type: "local",
|
||||
};
|
||||
|
||||
// Create the new user.
|
||||
const user = await create(db, tenant.id, {
|
||||
email,
|
||||
username,
|
||||
displayName,
|
||||
password,
|
||||
profiles: [profile],
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
});
|
||||
|
||||
// Send off to the passport handler.
|
||||
return handle(null, user)(req, res, next);
|
||||
} catch (err) {
|
||||
return handle(err)(req, res, next);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,13 +34,18 @@ export function createPassport({
|
||||
|
||||
export const handle = (
|
||||
err: Error | null,
|
||||
user: User | null
|
||||
user?: User | null
|
||||
): RequestHandler => (req: Request, res, next) => {
|
||||
if (err) {
|
||||
// TODO: wrap error?
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// TODO: replace with better error.
|
||||
return next(new Error("no user on request"));
|
||||
}
|
||||
|
||||
// Set the cache control headers.
|
||||
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
||||
res.header("Expires", "-1");
|
||||
|
||||
@@ -15,11 +15,11 @@ const verifyFactory = (db: Db) => async (
|
||||
done: VerifyCallback
|
||||
) => {
|
||||
try {
|
||||
// TODO: rate limit based on the IP address and user agent.
|
||||
|
||||
// The tenant is guaranteed at this point.
|
||||
const tenant = req.tenant!;
|
||||
|
||||
// TODO: rate limit the ip address
|
||||
|
||||
// Get the user from the database.
|
||||
const user = await retrieveUserWithProfile(db, tenant.id, {
|
||||
id: email,
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Strategy } from "passport-strategy";
|
||||
import { reconstructURL } from "talk-server/app/url";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant";
|
||||
import { createUser, retrieveUserWithProfile } from "talk-server/models/user";
|
||||
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
|
||||
import { create } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { VerifyCallback } from "./index";
|
||||
@@ -50,7 +51,7 @@ export async function findOrCreateOIDCUser(
|
||||
{ iss, sub, email, email_verified }: OIDCIDToken
|
||||
) {
|
||||
// Construct the profile that will be used to query for the user.
|
||||
const profile = {
|
||||
const profile: OIDCProfile = {
|
||||
type: "oidc",
|
||||
provider: iss,
|
||||
id: sub,
|
||||
@@ -62,7 +63,7 @@ export async function findOrCreateOIDCUser(
|
||||
// FIXME: implement rules.
|
||||
|
||||
// Create the new user, as one didn't exist before!
|
||||
user = await createUser(db, tenant.id, {
|
||||
user = await create(db, tenant.id, {
|
||||
username: null,
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
@@ -157,7 +158,11 @@ export default class OIDCStrategy extends Strategy {
|
||||
const { tenant } = req;
|
||||
|
||||
// Grab the JWKSClient.
|
||||
const client = this.lookupJWKSClient(req, tenant!.id, tenant!.auth.oidc!);
|
||||
const client = this.lookupJWKSClient(
|
||||
req,
|
||||
tenant!.id,
|
||||
tenant!.auth.integrations.oidc!
|
||||
);
|
||||
|
||||
// Verify that the id_token is valid or not.
|
||||
jwt.verify(
|
||||
@@ -180,7 +185,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
});
|
||||
},
|
||||
{
|
||||
issuer: tenant!.auth.oidc!.issuer,
|
||||
issuer: tenant!.auth.integrations.oidc!.issuer,
|
||||
},
|
||||
(err, decoded) => {
|
||||
if (err) {
|
||||
@@ -226,7 +231,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
|
||||
// Get the integration from the tenant. If needed, it will be used to create
|
||||
// a new strategy.
|
||||
const integration = tenant.auth.oidc;
|
||||
const integration = tenant.auth.integrations.oidc;
|
||||
if (!integration) {
|
||||
// TODO: return a better error.
|
||||
throw new Error("integration not found");
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Joi from "joi";
|
||||
|
||||
/**
|
||||
* validate will strip unknown fields and perform validation against it. It will
|
||||
* throw any error encountered.
|
||||
*
|
||||
* @param schema the Joi schema to validate against
|
||||
* @param body the body to parse and strip of unknown fields
|
||||
*/
|
||||
export const validate = (schema: Joi.SchemaLike, body: any) => {
|
||||
// Extract the schema from the request.
|
||||
const { value, error: err } = Joi.validate(body, schema, {
|
||||
stripUnknown: true,
|
||||
presence: "required",
|
||||
});
|
||||
|
||||
if (err) {
|
||||
// TODO: return better error.
|
||||
throw new Error("Validation Error");
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -39,7 +39,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) {
|
||||
express.json(),
|
||||
authenticate(options.passport, "local")
|
||||
);
|
||||
router.use("/auth/local/signup", express.json(), signup);
|
||||
router.use("/auth/local/signup", express.json(), signup({ db: app.mongo }));
|
||||
router.use("/auth/oidc", authenticate(options.passport, "oidc"));
|
||||
router.use("/auth/oidc/callback", authenticate(options.passport, "oidc"));
|
||||
// router.use("/auth/google", options.passport.authenticate("google"));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { AuthIntegration, AuthIntegrations } from "talk-server/models/tenant";
|
||||
|
||||
const disabled: AuthIntegration = { enabled: false };
|
||||
|
||||
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<AuthIntegrations> = {
|
||||
local: auth => auth.local || disabled,
|
||||
sso: auth => auth.sso || disabled,
|
||||
oidc: auth => auth.oidc || disabled,
|
||||
google: auth => auth.google || disabled,
|
||||
facebook: auth => auth.facebook || disabled,
|
||||
};
|
||||
|
||||
export default AuthIntegrations;
|
||||
@@ -1,14 +1,8 @@
|
||||
import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Auth, AuthIntegration } from "talk-server/models/tenant";
|
||||
|
||||
const disabled: AuthIntegration = { enabled: false };
|
||||
import { Auth } from "talk-server/models/tenant";
|
||||
|
||||
const AuthSettings: GQLAuthSettingsTypeResolver<Auth> = {
|
||||
local: auth => auth.local || disabled,
|
||||
sso: auth => auth.sso || disabled,
|
||||
oidc: auth => auth.oidc || disabled,
|
||||
google: auth => auth.google || disabled,
|
||||
facebook: auth => auth.facebook || disabled,
|
||||
integrations: auth => auth.integrations,
|
||||
};
|
||||
|
||||
export default AuthSettings;
|
||||
|
||||
@@ -2,16 +2,32 @@ import Cursor from "talk-server/graph/common/scalars/cursor";
|
||||
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import Asset from "./asset";
|
||||
import AuthIntegrations from "./auth_integrations";
|
||||
import AuthSettings from "./auth_settings";
|
||||
import Comment from "./comment";
|
||||
import FacebookAuthIntegration from "./facebook_auth_integration";
|
||||
import GoogleAuthIntegration from "./google_auth_integration";
|
||||
import LocalAuthIntegration from "./local_auth_integration";
|
||||
import Mutation from "./mutation";
|
||||
import OIDCAuthIntegration from "./oidc_auth_integration";
|
||||
import Profile from "./profile";
|
||||
import Query from "./query";
|
||||
import SSOAuthIntegration from "./sso_auth_integration";
|
||||
|
||||
const Resolvers: GQLResolver = {
|
||||
Asset,
|
||||
AuthIntegrations,
|
||||
AuthSettings,
|
||||
Comment,
|
||||
FacebookAuthIntegration,
|
||||
GoogleAuthIntegration,
|
||||
LocalAuthIntegration,
|
||||
OIDCAuthIntegration,
|
||||
SSOAuthIntegration,
|
||||
Cursor,
|
||||
Query,
|
||||
Mutation,
|
||||
Profile,
|
||||
Query,
|
||||
};
|
||||
|
||||
export default Resolvers;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { Profile } from "talk-server/models/user";
|
||||
|
||||
const resolveType: GQLProfileTypeResolver<Profile> = profile => {
|
||||
switch (profile.type) {
|
||||
case "local":
|
||||
return "LocalProfile";
|
||||
case "oidc":
|
||||
return "OIDCProfile";
|
||||
case "sso":
|
||||
return "SSOProfile";
|
||||
default:
|
||||
// TODO: replace with better error.
|
||||
throw new Error("invalid profile type");
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
__resolveType: resolveType,
|
||||
};
|
||||
@@ -132,10 +132,7 @@ type FacebookAuthIntegration {
|
||||
config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
"""
|
||||
AuthSettings contains all the settings related to authentication and authorization.
|
||||
"""
|
||||
type AuthSettings {
|
||||
type AuthIntegrations {
|
||||
local: LocalAuthIntegration!
|
||||
sso: SSOAuthIntegration!
|
||||
oidc: OIDCAuthIntegration!
|
||||
@@ -143,6 +140,24 @@ type AuthSettings {
|
||||
facebook: FacebookAuthIntegration!
|
||||
}
|
||||
|
||||
"""
|
||||
AuthSettings contains all the settings related to authentication and
|
||||
authorization.
|
||||
"""
|
||||
type AuthSettings {
|
||||
"""
|
||||
displayNameEnable when enabled, will allow Users to set and view their
|
||||
displayName's.
|
||||
"""
|
||||
displayNameEnable: Boolean!
|
||||
|
||||
"""
|
||||
integrations are the set of configurations for the variations of
|
||||
authentication solutions.
|
||||
"""
|
||||
integrations: AuthIntegrations!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Settings
|
||||
################################################################################
|
||||
@@ -319,6 +334,21 @@ enum USER_USERNAME_STATUS {
|
||||
CHANGED
|
||||
}
|
||||
|
||||
type LocalProfile {
|
||||
id: String!
|
||||
}
|
||||
|
||||
type OIDCProfile {
|
||||
id: String!
|
||||
provider: String!
|
||||
}
|
||||
|
||||
type SSOProfile {
|
||||
id: String!
|
||||
}
|
||||
|
||||
union Profile = LocalProfile | OIDCProfile | SSOProfile
|
||||
|
||||
"""
|
||||
User is someone that leaves Comments, and logs in.
|
||||
"""
|
||||
@@ -333,6 +363,16 @@ type User {
|
||||
"""
|
||||
username: String!
|
||||
|
||||
"""
|
||||
displayName is provided optionally when enabled and available.
|
||||
"""
|
||||
displayName: String
|
||||
|
||||
"""
|
||||
profiles is the array of profiles assigned to the user.
|
||||
"""
|
||||
profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
|
||||
|
||||
"""
|
||||
role is the current role of the User.
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Omit, Sub } from "talk-common/types";
|
||||
import { GQLCOMMENT_SORT } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
GQLCOMMENT_SORT,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ActionCounts } from "talk-server/models/actions";
|
||||
import { Connection, Cursor } from "talk-server/models/connection";
|
||||
import Query from "talk-server/models/query";
|
||||
@@ -19,19 +22,11 @@ export interface BodyHistoryItem {
|
||||
}
|
||||
|
||||
export interface StatusHistoryItem {
|
||||
status: CommentStatus; // TODO: migrate field
|
||||
status: GQLCOMMENT_STATUS; // TODO: migrate field
|
||||
assigned_by?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export enum CommentStatus {
|
||||
ACCEPTED = "ACCEPTED",
|
||||
REJECTED = "REJECTED",
|
||||
PREMOD = "PREMOD",
|
||||
SYSTEM_WITHHELD = "SYSTEM_WITHHELD",
|
||||
NONE = "NONE",
|
||||
}
|
||||
|
||||
export interface Comment extends TenantResource {
|
||||
readonly id: string;
|
||||
parent_id: string | null;
|
||||
@@ -39,7 +34,7 @@ export interface Comment extends TenantResource {
|
||||
asset_id: string;
|
||||
body: string;
|
||||
body_history: BodyHistoryItem[];
|
||||
status: CommentStatus;
|
||||
status: GQLCOMMENT_STATUS;
|
||||
status_history: StatusHistoryItem[];
|
||||
action_counts: ActionCounts;
|
||||
reply_count: number;
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Sub } from "talk-common/types";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
GQLMODERATION_MODE,
|
||||
GQLUSER_ROLE,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
function collection(db: Db) {
|
||||
return db.collection<Readonly<Tenant>>("tenants");
|
||||
@@ -19,11 +22,6 @@ export interface Wordlist {
|
||||
suspect: string[];
|
||||
}
|
||||
|
||||
export enum Moderation {
|
||||
PRE = "PRE",
|
||||
POST = "POST",
|
||||
}
|
||||
|
||||
// AuthIntegrations.
|
||||
|
||||
export interface EmailDomainRuleCondition {
|
||||
@@ -90,8 +88,8 @@ export interface GoogleAuthIntegration extends AuthIntegration {
|
||||
|
||||
export type LocalAuthIntegration = AuthIntegration;
|
||||
|
||||
// Auth describes all of the possible auth integration configurations.
|
||||
export interface Auth {
|
||||
// AuthIntegrations describes all of the possible auth integration configurations.
|
||||
export interface AuthIntegrations {
|
||||
// local is the auth integration for the local auth.
|
||||
local: LocalAuthIntegration;
|
||||
|
||||
@@ -108,6 +106,11 @@ export interface Auth {
|
||||
facebook?: FacebookAuthIntegration;
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
integrations: AuthIntegrations;
|
||||
displayNameEnable: boolean;
|
||||
}
|
||||
|
||||
// Tenant definition.
|
||||
|
||||
export interface Tenant {
|
||||
@@ -117,7 +120,7 @@ export interface Tenant {
|
||||
// specific tenant that the API request pertains to.
|
||||
domain: string;
|
||||
|
||||
moderation: Moderation;
|
||||
moderation: GQLMODERATION_MODE;
|
||||
requireEmailConfirmation: boolean;
|
||||
infoBoxEnable: boolean;
|
||||
infoBoxContent?: string;
|
||||
@@ -172,7 +175,7 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
|
||||
id: uuid.v4(),
|
||||
|
||||
// Default to post moderation.
|
||||
moderation: Moderation.POST,
|
||||
moderation: GQLMODERATION_MODE.POST,
|
||||
|
||||
// Email confirmation is default off.
|
||||
requireEmailConfirmation: false,
|
||||
@@ -191,8 +194,12 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
|
||||
banned: [],
|
||||
},
|
||||
auth: {
|
||||
local: {
|
||||
enabled: true,
|
||||
// Disable the displayName by default.
|
||||
displayNameEnable: false,
|
||||
integrations: {
|
||||
local: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,12 +15,24 @@ function collection(db: Db) {
|
||||
return db.collection<Readonly<User>>("users");
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
provider?: string;
|
||||
export interface LocalProfile {
|
||||
type: "local";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface OIDCProfile {
|
||||
type: "oidc";
|
||||
id: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface SSOProfile {
|
||||
type: "sso";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type Profile = LocalProfile | OIDCProfile | SSOProfile;
|
||||
|
||||
export interface Token {
|
||||
readonly id: string;
|
||||
name: string;
|
||||
@@ -28,14 +40,14 @@ export interface Token {
|
||||
}
|
||||
|
||||
export interface UserStatusHistory<T> {
|
||||
status: T; // TODO: migrate field
|
||||
status: T;
|
||||
assigned_by?: string;
|
||||
reason?: string; // TODO: migrate field
|
||||
reason?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface UserStatusItem<T> {
|
||||
status: T; // TODO: migrate field
|
||||
status: T;
|
||||
history: Array<UserStatusHistory<T>>;
|
||||
}
|
||||
|
||||
@@ -48,6 +60,7 @@ export interface UserStatus {
|
||||
export interface User extends TenantResource {
|
||||
readonly id: string;
|
||||
username: string | null;
|
||||
displayName?: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
@@ -56,7 +69,7 @@ export interface User extends TenantResource {
|
||||
role: GQLUSER_ROLE;
|
||||
status: UserStatus;
|
||||
action_counts: ActionCounts;
|
||||
ignored_users: string[]; // TODO: migrate field
|
||||
ignored_users: string[];
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Db } from "mongodb";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import {
|
||||
Comment,
|
||||
CommentStatus,
|
||||
createComment,
|
||||
CreateCommentInput,
|
||||
@@ -13,11 +12,7 @@ export type CreateComment = Omit<
|
||||
"status" | "action_counts"
|
||||
>;
|
||||
|
||||
export async function create(
|
||||
db: Db,
|
||||
tenantID: string,
|
||||
input: CreateComment
|
||||
): Promise<Comment> {
|
||||
export async function create(db: Db, tenantID: string, input: CreateComment) {
|
||||
// TODO: run the comment through the moderation phases.
|
||||
const comment = await createComment(db, tenantID, {
|
||||
status: CommentStatus.ACCEPTED,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { createUser, CreateUserInput } from "talk-server/models/user";
|
||||
|
||||
export type CreateUser = CreateUserInput;
|
||||
|
||||
export async function create(db: Db, tenantID: string, input: CreateUser) {
|
||||
const user = await createUser(db, tenantID, input);
|
||||
|
||||
return user;
|
||||
}
|
||||
Reference in New Issue
Block a user