From 33c7b7e1ff337e6387d55c7ec582b52788737964 Mon Sep 17 00:00:00 2001 From: Jerome Hardaway Date: Sat, 15 Jun 2024 19:19:33 -0400 Subject: [PATCH] Add GitHub login with organization and group restrictions --- README.md | 31 ++++++++++ components/forms/login-form.tsx | 72 +++++++++++++++++++++++ env.local | 4 ++ pages/api/auth/[...nextauth].ts | 28 +++++++++ pages/api/auth/group-membership.js | 46 +++++++++++++++ pages/api/auth/organization-membership.js | 25 ++++++++ tailwind.config.js | 23 +++----- 7 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 components/forms/login-form.tsx create mode 100644 pages/api/auth/[...nextauth].ts create mode 100644 pages/api/auth/group-membership.js create mode 100644 pages/api/auth/organization-membership.js diff --git a/README.md b/README.md index b2f365459..058480661 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,34 @@ Curious about upcoming features? Check our [Roadmap](https://github.com/orgs/Vet ## License :scroll: This project is under the MIT License - see the [License](https://github.com/Vets-Who-Code/vwc-site/blob/master/LICENSE) for more details. + +## GitHub OAuth Setup Instructions + +To authenticate users via GitHub and restrict access to members of the Vets Who Code organization, follow these steps: + +1. **Create a GitHub OAuth App**: Go to your GitHub settings, navigate to Developer settings > OAuth Apps, and create a new OAuth app. +2. **Application Name**: Give your application a name that reflects your project. +3. **Homepage URL**: Enter the URL of your application. +4. **Authorization callback URL**: This is critical. Enter `http://localhost:3000/api/auth/callback/github` for development. Adjust the domain accordingly for production. +5. **Client ID & Client Secret**: Once the application is created, GitHub will provide a Client ID and a Client Secret. Keep these confidential. + +Add the Client ID and Client Secret to your `.env.local` file: + +```plaintext +GITHUB_ID=your-github-client-id +GITHUB_SECRET=your-github-client-secret +``` + +## Configuring Access Restrictions + +To configure access restrictions based on organization and group membership, follow these steps: + +1. **Verify Organization Membership**: Utilize the GitHub API to check if the authenticated user is a member of the Vets Who Code organization. +2. **Group-Based Access Control**: Further restrict access to users who are part of the "students" group within the Vets Who Code organization. +3. **Environment Variables**: Ensure you have the Vets Who Code GitHub organization ID in your `.env.local` file: + +```plaintext +GITHUB_ORGANIZATION_ID=vets-who-code +``` + +These steps ensure that only authorized members of the Vets Who Code community can access certain parts of the application. diff --git a/components/forms/login-form.tsx b/components/forms/login-form.tsx new file mode 100644 index 000000000..adf75b284 --- /dev/null +++ b/components/forms/login-form.tsx @@ -0,0 +1,72 @@ +import { signIn, useSession } from "next-auth/react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import Button from "@ui/button"; +import Input from "@ui/form-elements/input"; +import Feedback from "@ui/form-elements/feedback"; + +const LoginForm = () => { + const { data: session } = useSession(); + const [loginError, setLoginError] = useState(""); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const onSubmit = async (data) => { + const result = await signIn("github", { + redirect: false, + ...data, + }); + + if (result?.error) { + setLoginError(result.error); + } + }; + + if (session) { + return ( +
+

You are already logged in

+
+ ); + } + + return ( +
+ {loginError && {loginError}} +
+ + + {errors.username && {errors.username.message}} +
+
+ + + {errors.password && {errors.password.message}} +
+
+ +
+
+ ); +}; + +export default LoginForm; diff --git a/env.local b/env.local index 2bb5f4285..f03e6d790 100644 --- a/env.local +++ b/env.local @@ -1 +1,5 @@ NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-WSXY307CRR +GITHUB_ID=your-github-client-id +GITHUB_SECRET=your-github-client-secret +NEXTAUTH_URL=http://localhost:3000 +GITHUB_ORGANIZATION_ID=vets-who-code diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts new file mode 100644 index 000000000..b43f64b3e --- /dev/null +++ b/pages/api/auth/[...nextauth].ts @@ -0,0 +1,28 @@ +import NextAuth from "next-auth"; +import GitHubProvider from "next-auth/providers/github"; +import { checkOrganizationMembership, checkGroupMembership } from "./membership-utils"; + +export default NextAuth({ + providers: [ + GitHubProvider({ + clientId: process.env.GITHUB_ID, + clientSecret: process.env.GITHUB_SECRET, + }), + ], + callbacks: { + async signIn({ user, account, profile }) { + if (account.provider === "github") { + const isMember = await checkOrganizationMembership(account.accessToken); + if (!isMember) { + return false; // Not a member of the organization + } + + const isInStudentsGroup = await checkGroupMembership(account.accessToken, user.id); + if (!isInStudentsGroup) { + return false; // Not in the "students" group + } + } + return true; // Sign in successful + }, + }, +}); diff --git a/pages/api/auth/group-membership.js b/pages/api/auth/group-membership.js new file mode 100644 index 000000000..a2ec155d1 --- /dev/null +++ b/pages/api/auth/group-membership.js @@ -0,0 +1,46 @@ +import { Octokit } from "@octokit/core"; + +const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); + +export async function checkGroupMembership(userAccessToken, userId) { + try { + const orgs = await octokit.request("GET /user/orgs", { + headers: { + authorization: `token ${userAccessToken}`, + }, + }); + + const vetsWhoCodeOrg = orgs.data.find(org => org.login === "Vets-Who-Code"); + + if (!vetsWhoCodeOrg) { + return false; // User is not part of the Vets Who Code organization + } + + const teams = await octokit.request("GET /orgs/{org}/teams", { + org: vetsWhoCodeOrg.login, + headers: { + authorization: `token ${userAccessToken}`, + }, + }); + + const studentsTeam = teams.data.find(team => team.name === "students"); + + if (!studentsTeam) { + return false; // "students" team does not exist within the organization + } + + const membership = await octokit.request("GET /orgs/{org}/teams/{team_slug}/memberships/{username}", { + org: vetsWhoCodeOrg.login, + team_slug: studentsTeam.slug, + username: userId, + headers: { + authorization: `token ${userAccessToken}`, + }, + }); + + return membership.data.state === "active"; + } catch (error) { + console.error("Error checking group membership:", error); + return false; + } +} diff --git a/pages/api/auth/organization-membership.js b/pages/api/auth/organization-membership.js new file mode 100644 index 000000000..2aa9e52c1 --- /dev/null +++ b/pages/api/auth/organization-membership.js @@ -0,0 +1,25 @@ +import { Octokit } from "@octokit/core"; + +const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); + +export async function checkOrganizationMembership(userAccessToken) { + try { + const response = await octokit.request("GET /user/memberships/orgs", { + headers: { + authorization: `token ${userAccessToken}`, + }, + }); + + const isMember = response.data.some( + (membership) => + membership.organization.login.toLowerCase() === + "vets-who-code".toLowerCase() && + membership.state === "active" + ); + + return isMember; + } catch (error) { + console.error("Error checking organization membership:", error); + return false; + } +} diff --git a/tailwind.config.js b/tailwind.config.js index 278c9304c..7b322b00a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -104,6 +104,15 @@ module.exports = { mandy: "#df5b6c", tan: "#d2a98e", mishcka: "#e2e2e8", + // Custom styles for the login page + login: { + background: "#f5f5f5", + inputBorder: "#e2e8f0", + inputFocusBorder: "#93c5fd", + buttonBackground: "#4f46e5", + buttonHoverBackground: "#4338ca", + buttonText: "#ffffff", + }, }, typography: ({ theme }) => ({ DEFAULT: { @@ -174,29 +183,15 @@ module.exports = { }, screens: { maxSm: { max: "575px" }, - // => @media (max-width: 575px) { ... } maxXl: { max: "1199px" }, - // => @media (max-width: 1199px) { ... } maxLg: { max: "991px" }, - // => @media (max-width: 991px) { ... } smToMd: { min: "576px", max: "767px" }, sm: "576px", - // => @media (min-width: 576px) { ... } - md: "768px", - // => @media (min-width: 768px) { ... } - lg: "992px", - // => @media (min-width: 992px) { ... } - xl: "1200px", - // => @media (min-width: 1200px) { ... } - "2xl": "1400px", - // => @media (min-width: 1400px) { ... } - "3xl": "1600px", - // => @media (min-width: 1600px) { ... } }, zIndex: { 1: 1,