diff --git a/.changeset/quick-stingrays-sing.md b/.changeset/quick-stingrays-sing.md new file mode 100644 index 00000000000..76a494a41c9 --- /dev/null +++ b/.changeset/quick-stingrays-sing.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/example-next-auth': major +--- + +Initial version of the `withAuth` example. diff --git a/docs/components/Navigation.tsx b/docs/components/Navigation.tsx index 90e395b3105..379db8fe769 100644 --- a/docs/components/Navigation.tsx +++ b/docs/components/Navigation.tsx @@ -65,6 +65,9 @@ export function Navigation() { Getting started + + Authentication +
Keystone 5 vs Next diff --git a/docs/pages/tutorials/with-auth.mdx b/docs/pages/tutorials/with-auth.mdx new file mode 100644 index 00000000000..0ebdcecb296 --- /dev/null +++ b/docs/pages/tutorials/with-auth.mdx @@ -0,0 +1,112 @@ +import { Markdown } from '../../components/Page'; + +Authentication + +In this tutorial we're going to demonstrate how to add password based authentication to your Keystone system. + +This tutorial builds on the [FIXME](./fixme) tutorials, so make sure you've completed that first before starting this one. +If you want to jump straight in. you can grab the starting code from [FIXME](./FIXME). + +By the end of the tutorial you'll be able to add users with passwords to your task manager. and they'll be able to: + + * Configures which fields to use for signin + * Sets up stateless session handling to keep track of the signed in user + * Adds a signin screen to the Admin UI + * Adds a signout button to the Admin UI + * Allows the signed in user to access their own details in the Admin UI + * Adds a helper page to the Admin UI to allow you to create your first user when starting from an empty database + + +This project demonstrates how to add password based authentication to your Keystone system. +It builds on the [todo](../) starter project. + +## Instructions + +To run this project, clone the Keystone repository locally then navigate to this directory and run: + +```shell +yarn dev +``` + +This will start the Admin UI at [localhost:3000](http://localhost:3000). +You can use the Admin UI to create items in your database. + +You can also access a GraphQL Playground at [localhost:3000/api/graphql](http://localhost:3000/api/graphql), which allows you to directly run GraphQL queries and mutations. + +## Features + +This project shows you how to add authentication to your Keystone system. We're going to use the [`@keystone-next/auth`](https://next.keystonejs.com/apis/auth) package, along with Keystone's [session management API](https://next.keystonejs.com/apis/session), to add the following features to your system: + + * Configures which fields to use for signin + * Sets up stateless session handling to keep track of the signed in user + * Adds a signin screen to the Admin UI + * Adds a signout button to the Admin UI + * Allows the signed in user to access their own details in the Admin UI + * Adds a helper page to the Admin UI to allow you to create your first user when starting from an empty database + +### Added fields + +We start by adding two new fields, `email` and `password`, to the `Person` list. +These will be used as our _identity_ and _secret_ fields for login. + +```typescript + email: text({ isRequired: true, isUnique: true }), + password: password({ isRequired: true }), +``` + +### Auth config + +```typescript +const { withAuth } = createAuth({ + listKey: 'Person', + identityField: 'email', + secretField: 'password', + initFirstItem: { fields: ['name', 'email', 'password'] }, +}); +``` + +### Session + +```typescript +const session = statelessSessions({ secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --' }); +``` + +### Wrapped config + +```typescript +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + }, + lists, + session, + }) +); +``` + +## Screenshots + +### Admin UI + +Screenshots of the first item experience, mailing list experience(?), login screen, the "logged in as" bit, logout button. +![initial user screen](../../screenshots/init-user-01.png) +![mailing list subscription screen](../../screenshots/mailing-list-01.png) +![sign in screen](../../screenshots/sign-in-screen-01.png) + +### GraphQL Playground + +Show off the new API that this has added and how to use it + +### Somehow show session cookie data. + +## Next steps + +This project is a bare bones system, and doesn't use any of Keystone's advanced features. +We encourage you to experiment with the code here to see how Keystone works, become familiar with the Admin UI, and learn about the GraphQL Playground. + +Once you've got the hang of using this project, you can check out the [feature examples](../). +These projects build on this starter project and show you how to use Keystones advanced features to take your project to the next level. + +export default ({ children }) => {children} diff --git a/examples/README.md b/examples/README.md index 32ca57d81f2..698dc576a83 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,7 +18,7 @@ You can use these as a starting point for general experimentation in a clean env Each of the examples below demonstrates a particular feature of Keystone. You can use these projects to learn about, and experiment with specific features. -(coming soon) + * [`withAuth`](./with-auth): Adding password based authentication to your Keystone application. ## Solution Examples diff --git a/examples/with-auth/CHANGELOG.md b/examples/with-auth/CHANGELOG.md new file mode 100644 index 00000000000..b52cadc6fe3 --- /dev/null +++ b/examples/with-auth/CHANGELOG.md @@ -0,0 +1 @@ +# @keystone-next/example-with-auth diff --git a/examples/with-auth/README.md b/examples/with-auth/README.md new file mode 100644 index 00000000000..9d355b95950 --- /dev/null +++ b/examples/with-auth/README.md @@ -0,0 +1,93 @@ +## Feature Example - Authentication + +This project demonstrates how to add password based authentication to your Keystone system. +It builds on the [todo](../) starter project. + +## Instructions + +To run this project, clone the Keystone repository locally then navigate to this directory and run: + +```shell +yarn dev +``` + +This will start the Admin UI at [localhost:3000](http://localhost:3000). +You can use the Admin UI to create items in your database. + +You can also access a GraphQL Playground at [localhost:3000/api/graphql](http://localhost:3000/api/graphql), which allows you to directly run GraphQL queries and mutations. + +## Features + +This project shows you how to add authentication to your Keystone system. We're going to use the [`@keystone-next/auth`](https://next.keystonejs.com/apis/auth) package, along with Keystone's [session management API](https://next.keystonejs.com/apis/session), to add the following features to your system: + + * Configures which fields to use for signin + * Sets up stateless session handling to keep track of the signed in user + * Adds a signin screen to the Admin UI + * Adds a signout button to the Admin UI + * Allows the signed in user to access their own details in the Admin UI + * Adds a helper page to the Admin UI to allow you to create your first user when starting from an empty database + +### Added fields + +We start by adding two new fields, `email` and `password`, to the `Person` list. +These will be used as our _identity_ and _secret_ fields for login. + +```typescript + email: text({ isRequired: true, isUnique: true }), + password: password({ isRequired: true }), +``` + +### Auth config + +```typescript +const { withAuth } = createAuth({ + listKey: 'Person', + identityField: 'email', + secretField: 'password', + initFirstItem: { fields: ['name', 'email', 'password'] }, +}); +``` + +### Session + +```typescript +const session = statelessSessions({ secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --' }); +``` + +### Wrapped config + +```typescript +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + }, + lists, + session, + }) +); +``` + +## Screenshots + +### Admin UI + +Screenshots of the first item experience, mailing list experience(?), login screen, the "logged in as" bit, logout button. +![initial user screen](../../screenshots/init-user-01.png) +![mailing list subscription screen](../../screenshots/mailing-list-01.png) +![sign in screen](../../screenshots/sign-in-screen-01.png) + +### GraphQL Playground + +Show off the new API that this has added and how to use it + +### Somehow show session cookie data. + +## Next steps + +This project is a bare bones system, and doesn't use any of Keystone's advanced features. +We encourage you to experiment with the code here to see how Keystone works, become familiar with the Admin UI, and learn about the GraphQL Playground. + +Once you've got the hang of using this project, you can check out the [feature examples](../). +These projects build on this starter project and show you how to use Keystones advanced features to take your project to the next level. diff --git a/examples/with-auth/keystone.ts b/examples/with-auth/keystone.ts new file mode 100644 index 00000000000..c9c939103ed --- /dev/null +++ b/examples/with-auth/keystone.ts @@ -0,0 +1,45 @@ +import { config } from '@keystone-next/keystone/schema'; +import { statelessSessions } from '@keystone-next/keystone/session'; +import { createAuth } from '@keystone-next/auth'; +import { lists } from './schema'; + +// createAuth configures signin functionality based on the config below. Note this only implements +// authentication, i.e signing in as an item using identity and secret fields in a list. Session +// management and access control are controlled independently in the main keystone config. +const { withAuth } = createAuth({ + // This is the list that contains items people can sign in as + listKey: 'Person', + // The identity field is typically a username or email address + identityField: 'email', + // The secret field must be a password type field + secretField: 'password', + + // initFirstItem turns on the "First User" experience, which prompts you to create a new user + // when there are no items in the list yet + initFirstItem: { + // These fields are collected in the "Create First User" form + fields: ['name', 'email', 'password'], + }, +}); + +// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. +// This session object will be made availble on the context object used in hooks, access-control, +// resolvers, etc. +const session = statelessSessions({ + // The session secret is used to encrypt cookie data (should be an environment variable) + secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', +}); + +// We wrap our config using the withAuth function. This will inject all +// the extra config required to add support for authentication in our system. +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + }, + lists, + // We add our session configuration to the system here. + session, + }) +); diff --git a/examples/with-auth/package.json b/examples/with-auth/package.json new file mode 100644 index 00000000000..b4721775ac5 --- /dev/null +++ b/examples/with-auth/package.json @@ -0,0 +1,23 @@ +{ + "name": "@keystone-next/example-with-auth", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone-next dev", + "start": "keystone-next start", + "build": "keystone-next build" + }, + "dependencies": { + "@keystone-next/auth": "^23.0.0", + "@keystone-next/fields": "^8.0.0", + "@keystone-next/keystone": "^17.0.0" + }, + "devDependencies": { + "typescript": "^4.2.4" + }, + "engines": { + "node": ">=v12.13.1" + }, + "repository": "https://github.com/keystonejs/keystone/tree/master/examples/with-auth" +} diff --git a/examples/with-auth/schema.graphql b/examples/with-auth/schema.graphql new file mode 100644 index 00000000000..4de805e37b7 --- /dev/null +++ b/examples/with-auth/schema.graphql @@ -0,0 +1,470 @@ +enum TaskPriorityType { + low + medium + high +} + +input PersonRelateToOneInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput + disconnect: PersonWhereUniqueInput + disconnectAll: Boolean +} + +""" + A keystone list +""" +type Task { + id: ID! + label: String + priority: TaskPriorityType + isComplete: Boolean + assignedTo: Person + finishBy: String +} + +input TaskWhereInput { + AND: [TaskWhereInput] + OR: [TaskWhereInput] + id: ID + id_not: ID + id_lt: ID + id_lte: ID + id_gt: ID + id_gte: ID + id_in: [ID] + id_not_in: [ID] + label: String + label_not: String + label_contains: String + label_not_contains: String + label_in: [String] + label_not_in: [String] + priority: TaskPriorityType + priority_not: TaskPriorityType + priority_in: [TaskPriorityType] + priority_not_in: [TaskPriorityType] + isComplete: Boolean + isComplete_not: Boolean + assignedTo: PersonWhereInput + assignedTo_is_null: Boolean + finishBy: String + finishBy_not: String + finishBy_lt: String + finishBy_lte: String + finishBy_gt: String + finishBy_gte: String + finishBy_in: [String] + finishBy_not_in: [String] +} + +input TaskWhereUniqueInput { + id: ID! +} + +enum SortTasksBy { + id_ASC + id_DESC + label_ASC + label_DESC + priority_ASC + priority_DESC + isComplete_ASC + isComplete_DESC + finishBy_ASC + finishBy_DESC +} + +input TaskUpdateInput { + label: String + priority: TaskPriorityType + isComplete: Boolean + assignedTo: PersonRelateToOneInput + finishBy: String +} + +input TasksUpdateInput { + id: ID! + data: TaskUpdateInput +} + +input TaskCreateInput { + label: String + priority: TaskPriorityType + isComplete: Boolean + assignedTo: PersonRelateToOneInput + finishBy: String +} + +input TasksCreateInput { + data: TaskCreateInput +} + +input TaskRelateToManyInput { + create: [TaskCreateInput] + connect: [TaskWhereUniqueInput] + disconnect: [TaskWhereUniqueInput] + disconnectAll: Boolean +} + +""" + A keystone list +""" +type Person { + id: ID! + name: String + email: String + password_is_set: Boolean + tasks( + where: TaskWhereInput + search: String + sortBy: [SortTasksBy!] + orderBy: String + first: Int + skip: Int + ): [Task!]! + _tasksMeta( + where: TaskWhereInput + search: String + sortBy: [SortTasksBy!] + orderBy: String + first: Int + skip: Int + ): _QueryMeta +} + +input PersonWhereInput { + AND: [PersonWhereInput] + OR: [PersonWhereInput] + id: ID + id_not: ID + id_lt: ID + id_lte: ID + id_gt: ID + id_gte: ID + id_in: [ID] + id_not_in: [ID] + name: String + name_not: String + name_contains: String + name_not_contains: String + name_in: [String] + name_not_in: [String] + email: String + email_not: String + email_contains: String + email_not_contains: String + email_in: [String] + email_not_in: [String] + password_is_set: Boolean + + """ + condition must be true for all nodes + """ + tasks_every: TaskWhereInput + + """ + condition must be true for at least 1 node + """ + tasks_some: TaskWhereInput + + """ + condition must be false for all nodes + """ + tasks_none: TaskWhereInput +} + +input PersonWhereUniqueInput { + id: ID! +} + +enum SortPeopleBy { + id_ASC + id_DESC + name_ASC + name_DESC + email_ASC + email_DESC +} + +input PersonUpdateInput { + name: String + email: String + password: String + tasks: TaskRelateToManyInput +} + +input PeopleUpdateInput { + id: ID! + data: PersonUpdateInput +} + +input PersonCreateInput { + name: String + email: String + password: String + tasks: TaskRelateToManyInput +} + +input PeopleCreateInput { + data: PersonCreateInput +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + @specifiedBy( + url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" + ) + +type _QueryMeta { + count: Int +} + +type Mutation { + """ + Create a single Task item. + """ + createTask(data: TaskCreateInput): Task + + """ + Create multiple Task items. + """ + createTasks(data: [TasksCreateInput]): [Task] + + """ + Update a single Task item by ID. + """ + updateTask(id: ID!, data: TaskUpdateInput): Task + + """ + Update multiple Task items by ID. + """ + updateTasks(data: [TasksUpdateInput]): [Task] + + """ + Delete a single Task item by ID. + """ + deleteTask(id: ID!): Task + + """ + Delete multiple Task items by ID. + """ + deleteTasks(ids: [ID!]): [Task] + + """ + Create a single Person item. + """ + createPerson(data: PersonCreateInput): Person + + """ + Create multiple Person items. + """ + createPeople(data: [PeopleCreateInput]): [Person] + + """ + Update a single Person item by ID. + """ + updatePerson(id: ID!, data: PersonUpdateInput): Person + + """ + Update multiple Person items by ID. + """ + updatePeople(data: [PeopleUpdateInput]): [Person] + + """ + Delete a single Person item by ID. + """ + deletePerson(id: ID!): Person + + """ + Delete multiple Person items by ID. + """ + deletePeople(ids: [ID!]): [Person] + authenticatePersonWithPassword( + email: String! + password: String! + ): PersonAuthenticationWithPasswordResult! + createInitialPerson( + data: CreateInitialPersonInput! + ): PersonAuthenticationWithPasswordSuccess! + endSession: Boolean! +} + +""" +The `Upload` scalar type represents a file upload. +""" +scalar Upload + +union AuthenticatedItem = Person + +union PersonAuthenticationWithPasswordResult = + PersonAuthenticationWithPasswordSuccess + | PersonAuthenticationWithPasswordFailure + +type PersonAuthenticationWithPasswordSuccess { + sessionToken: String! + item: Person! +} + +type PersonAuthenticationWithPasswordFailure { + code: PasswordAuthErrorCode! + message: String! +} + +enum PasswordAuthErrorCode { + FAILURE + IDENTITY_NOT_FOUND + SECRET_NOT_SET + MULTIPLE_IDENTITY_MATCHES + SECRET_MISMATCH +} + +input CreateInitialPersonInput { + name: String + email: String + password: String +} + +type Query { + """ + Search for all Task items which match the where clause. + """ + allTasks( + where: TaskWhereInput + search: String + sortBy: [SortTasksBy!] + orderBy: String + first: Int + skip: Int + ): [Task] + + """ + Search for the Task item with the matching ID. + """ + Task(where: TaskWhereUniqueInput!): Task + + """ + Perform a meta-query on all Task items which match the where clause. + """ + _allTasksMeta( + where: TaskWhereInput + search: String + sortBy: [SortTasksBy!] + orderBy: String + first: Int + skip: Int + ): _QueryMeta + + """ + Search for all Person items which match the where clause. + """ + allPeople( + where: PersonWhereInput + search: String + sortBy: [SortPeopleBy!] + orderBy: String + first: Int + skip: Int + ): [Person] + + """ + Search for the Person item with the matching ID. + """ + Person(where: PersonWhereUniqueInput!): Person + + """ + Perform a meta-query on all Person items which match the where clause. + """ + _allPeopleMeta( + where: PersonWhereInput + search: String + sortBy: [SortPeopleBy!] + orderBy: String + first: Int + skip: Int + ): _QueryMeta + authenticatedItem: AuthenticatedItem + keystone: KeystoneMeta! +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + enableSignout: Boolean! + enableSessionItem: Boolean! + lists: [KeystoneAdminUIListMeta!]! + list(key: String!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + itemQueryName: String! + listQueryName: String! + hideCreate: Boolean! + hideDelete: Boolean! + path: String! + label: String! + singular: String! + plural: String! + description: String + initialColumns: [String!]! + pageSize: Int! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + initialSort: KeystoneAdminUISort + isHidden: Boolean! +} + +type KeystoneAdminUIFieldMeta { + path: String! + label: String! + isOrderable: Boolean! + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + listView: KeystoneAdminUIFieldMetaListView! + itemView(id: ID!): KeystoneAdminUIFieldMetaItemView +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaCreateViewFieldMode { + edit + hidden +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaItemViewFieldMode { + edit + read + hidden +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/with-auth/schema.prisma b/examples/with-auth/schema.prisma new file mode 100644 index 00000000000..40835e596da --- /dev/null +++ b/examples/with-auth/schema.prisma @@ -0,0 +1,29 @@ +datasource sqlite { + url = env("DATABASE_URL") + provider = "sqlite" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/.prisma/client" +} + +model Task { + id Int @id @default(autoincrement()) + label String? + priority String? + isComplete Boolean? + finishBy DateTime? + assignedTo Person? @relation("TaskassignedTo", fields: [assignedToId], references: [id]) + assignedToId Int? @map("assignedTo") + + @@index([assignedToId]) +} + +model Person { + id Int @id @default(autoincrement()) + name String? + email String? @unique + password String? + tasks Task[] @relation("TaskassignedTo") +} \ No newline at end of file diff --git a/examples/with-auth/schema.ts b/examples/with-auth/schema.ts new file mode 100644 index 00000000000..4be26920552 --- /dev/null +++ b/examples/with-auth/schema.ts @@ -0,0 +1,36 @@ +import { createSchema, list } from '@keystone-next/keystone/schema'; +import { checkbox, password, relationship, text, timestamp } from '@keystone-next/fields'; +import { select } from '@keystone-next/fields'; + +export const lists = createSchema({ + Task: list({ + fields: { + label: text({ isRequired: true }), + priority: select({ + dataType: 'enum', + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + ], + }), + isComplete: checkbox(), + assignedTo: relationship({ ref: 'Person.tasks', many: false }), + finishBy: timestamp(), + }, + }), + Person: list({ + fields: { + name: text({ isRequired: true }), + // Added an email and password pair to be used with authentication + // The email address is going to be used as the identity field, so it's + // important that we set both isRequired and isUnique + email: text({ isRequired: true, isUnique: true }), + // The password field stores a hash of the supplied password, and + // we want to ensure that all people have a password set, so we use + // the isRequired flag. + password: password({ isRequired: true }), + tasks: relationship({ ref: 'Task.assignedTo', many: true }), + }, + }), +}); diff --git a/screenshots/init-user-01.png b/screenshots/init-user-01.png new file mode 100644 index 00000000000..7c3af0383fc Binary files /dev/null and b/screenshots/init-user-01.png differ diff --git a/screenshots/mailing-list-01.png b/screenshots/mailing-list-01.png new file mode 100644 index 00000000000..983882b67ee Binary files /dev/null and b/screenshots/mailing-list-01.png differ diff --git a/screenshots/sign-in-screen-01.png b/screenshots/sign-in-screen-01.png new file mode 100644 index 00000000000..f0ab01adbe7 Binary files /dev/null and b/screenshots/sign-in-screen-01.png differ