Add github auth (#68)

* Add github auth

* Add project mapping background jobs

* Explicitly allow redirect to github for signin
This commit is contained in:
Max Wofford
2025-03-19 14:19:43 -04:00
committed by GitHub
parent 06baa1dfef
commit a088df568a
12 changed files with 286 additions and 3 deletions

View File

@@ -140,6 +140,25 @@
color: white;
}
.auth-button.github {
background-color: #24292e;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
display: inline-block;
text-decoration: none;
text-align: center;
width: 100%;
transition: background-color 0.2s;
}
.auth-button.github:hover {
background-color: #2f363d;
}
.auth-divider {
margin: 1rem 0;
color: #666;
@@ -266,3 +285,20 @@
transform: translateY(-3px);
}
}
.button {
display: inline-block;
padding: 8px 16px;
background-color: #24292e;
color: white;
text-decoration: none;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.button:hover {
background-color: #2f363d;
}

View File

@@ -67,6 +67,7 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
ip_address: request.remote_ip
})
new_heartbeat = Heartbeat.find_or_create_by(attrs)
queue_project_mapping(heartbeat[:project])
results << [ new_heartbeat.attributes, 201 ]
rescue => e
Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}")
@@ -75,6 +76,10 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
results
end
def queue_project_mapping(project_name)
AttemptProjectRepoMappingJob.perform_later(@user.id, heartbeat[:project])
end
def set_user
api_header = request.headers["Authorization"]
raw_token = api_header&.split(" ")&.last

View File

@@ -33,6 +33,39 @@ class SessionsController < ApplicationController
end
end
def github_new
redirect_uri = url_for(action: :github_create, only_path: false)
Rails.logger.info "Starting GitHub OAuth flow with redirect URI: #{redirect_uri}"
redirect_to User.github_authorize_url(redirect_uri),
allow_other_host: "https://github.com"
end
def github_create
redirect_uri = url_for(action: :github_create, only_path: false)
if params[:error].present?
Rails.logger.error "GitHub OAuth error: #{params[:error]}"
redirect_to root_path, alert: "Failed to authenticate with GitHub"
return
end
@user = User.from_github_token(params[:code], redirect_uri, current_user)
if @user&.persisted?
session[:user_id] = @user.id unless current_user # Only set session if this is a new sign-in
if @user.data_migration_jobs.empty?
# if they don't have a data migration job, add one to the queue
OneTime::MigrateUserFromHackatimeJob.perform_later(@user.id)
end
redirect_to root_path, notice: current_user ? "Successfully linked GitHub account!" : "Successfully signed in with GitHub!"
else
Rails.logger.error "Failed to create/update user from GitHub data"
redirect_to root_path, alert: "Failed to sign in with GitHub"
end
end
def email
email = params[:email].downcase

View File

@@ -0,0 +1,64 @@
class AttemptProjectRepoMappingJob < ApplicationJob
queue_as :default
include GoodJob::ActiveJobExtensions::Concurrency
good_job_control_concurrency_with(
total_limit: 1,
key: -> { "attempt_project_repo_mapping_job_#{arguments.first}_#{arguments.last}" },
drop: true
)
def perform(user_id, project_name)
@user = User.find(user_id)
return unless @user.github_uid.present?
return unless @user.github_username.present?
return if @user.project_repo_mappings.exists?(project_name: project_name)
# Search for the project on GitHub
repo_url = search_for_repo(@user.github_username, project_name)
if repo_url.present?
puts "creating mapping"
create_mapping(project_name, repo_url)
return
end
# now search for orgs the user is a member of & check in those places for the repo
list_orgs.each do |org|
repo_url = search_for_repo(org["login"], project_name)
if repo_url.present?
create_mapping(project_name, repo_url)
return
end
end
end
private
def create_mapping(project_name, repo_url)
@user.project_repo_mappings.create!(project_name: project_name, repo_url: repo_url)
end
def search_for_repo(org_name, project_name)
puts "Searching for repo #{project_name} in user #{org_name}"
response = HTTP.auth("Bearer #{@user.github_access_token}")
.get("https://api.github.com/repos/#{org_name}/#{project_name}")
Rails.logger.info "GitHub org repos response: #{response.body}"
# if not found, return nil
return nil unless response.status.success?
repo = JSON.parse(response.body)
puts "repo: #{repo}"
repo["html_url"]
end
def list_orgs
response = HTTP.auth("Bearer #{@user.github_access_token}")
.get("https://api.github.com/users/#{@user.github_username}/orgs")
Rails.logger.info "GitHub orgs response: #{response.body}"
JSON.parse(response.body)
end
end

View File

@@ -0,0 +1,33 @@
class ScanGithubReposJob < ApplicationJob
queue_as :default
include GoodJob::ActiveJobExtensions::Concurrency
# Only allow one instance of this job to run at a time
good_job_control_concurrency_with(
total_limit: 1,
key: -> { "scan_github_repos_job_#{arguments.first.presence || 'all'}" },
drop: true
)
def perform(user_id = nil)
scope = User.where.not(github_uid: nil)
scope = scope.where(id: user_id) if user_id.present?
puts "Scanning GitHub repos for #{scope.count} users"
scope.find_each(batch_size: 100) do |user|
Rails.logger.info "Scanning GitHub repos for user #{user.id} (#{user.github_username})"
# existing mappings
existing_mappings = user.project_repo_mappings.pluck(:project_name)
# Get unique project names from user's heartbeats
project_names = user.heartbeats.where.not(project: existing_mappings)
.distinct.pluck(:project).compact
project_names.each do |project_name|
AttemptProjectRepoMappingJob.perform_later(user.id, project_name)
end
end
end
end

View File

@@ -1,8 +1,9 @@
class User < ApplicationRecord
has_paper_trail
encrypts :slack_access_token
encrypts :slack_access_token, :github_access_token
validates :slack_uid, uniqueness: true, allow_nil: true
validates :github_uid, uniqueness: true, allow_nil: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all.map(&:identifier) }, allow_nil: false
has_many :heartbeats
@@ -160,6 +161,17 @@ class User < ApplicationRecord
URI.parse("https://slack.com/oauth/v2/authorize?#{params.to_query}")
end
def self.github_authorize_url(redirect_uri)
params = {
client_id: ENV["GITHUB_CLIENT_ID"],
redirect_uri: redirect_uri,
state: SecureRandom.hex(24),
scope: "user:email"
}
URI.parse("https://github.com/login/oauth/authorize?#{params.to_query}")
end
def self.from_slack_token(code, redirect_uri)
# Exchange code for token
response = HTTP.post("https://slack.com/api/oauth.v2.access", form: {
@@ -208,8 +220,81 @@ class User < ApplicationRecord
nil
end
def self.from_github_token(code, redirect_uri, current_user = nil)
# Exchange code for token
response = HTTP.headers(accept: "application/json")
.post("https://github.com/login/oauth/access_token", form: {
client_id: ENV["GITHUB_CLIENT_ID"],
client_secret: ENV["GITHUB_CLIENT_SECRET"],
code: code,
redirect_uri: redirect_uri
})
data = JSON.parse(response.body.to_s)
Rails.logger.info "GitHub OAuth response: #{data.inspect}"
return nil unless data["access_token"]
# Get user info
user_response = HTTP.auth("Bearer #{data['access_token']}")
.get("https://api.github.com/user")
user_data = JSON.parse(user_response.body.to_s)
Rails.logger.info "GitHub user data: #{user_data.inspect}"
Rails.logger.info "GitHub user ID type: #{user_data['id'].class}"
# Get user email from profile
primary_email = user_data["email"]
return nil unless primary_email
# If we have a current user, update that user
if current_user
user = current_user
else
# For new sign-ins, try to find user by GitHub ID or email
user = User.find_by(github_uid: user_data["id"])
unless user
email_address = EmailAddress.find_by(email: primary_email)
user = email_address&.user
end
# If still no user found, create a new one
user ||= begin
u = User.new
u.email_addresses << EmailAddress.new(email: primary_email)
u
end
end
# Update GitHub-specific fields
user.github_uid = user_data["id"]
user.username ||= user_data["login"]
user.github_username = user_data["login"]
user.github_avatar_url = user_data["avatar_url"]
user.github_access_token = data["access_token"]
# Add the GitHub email if it's not already associated
unless user.email_addresses.exists?(email: primary_email)
begin
user.email_addresses << EmailAddress.new(email: primary_email)
rescue ActiveRecord::RecordInvalid => e
# If the email already exists for another user, we can ignore it
Rails.logger.info "Email #{primary_email} already exists for another user"
end
end
user.save!
ScanGithubReposJob.perform_later(user.id)
user
rescue => e
Rails.logger.error "Error creating user from GitHub data: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
nil
end
def avatar_url
return self.slack_avatar_url if self.slack_avatar_url.present?
return self.github_avatar_url if self.github_avatar_url.present?
initials = self.email_addresses&.first&.email[0..1]&.upcase
hashed_initials = Digest::SHA256.hexdigest(initials)[0..5]
"https://i2.wp.com/ui-avatars.com/api/#{initials}/48/#{hashed_initials}/fff?ssl=1" if initials.present?

View File

@@ -1,5 +1,5 @@
<%= turbo_frame_tag "activity_graph" do %>
<%= cache ["activity_graph", current_user.id, current_user.timezone], expires_in: 1.hours do %>
<%= cache ["activity_graph", current_user.id, current_user.timezone], expires_in: 1.minute do %>
<div class="activity-graph-container">
<div class="activity-graph">
<% (365.days.ago.to_date..Time.current.to_date).to_a.each do |date| %>

View File

@@ -43,6 +43,15 @@
<% end %>
</div>
<div>
<h2>GitHub Account</h2>
<% if @user.github_uid.present? %>
<p>Your GitHub account is linked. <%= link_to "@#{@user.github_username}", "https://github.com/#{@user.github_username}", target: "_blank" %></p>
<% else %>
<%= link_to "Link GitHub Account", github_auth_path, class: "button" %>
<% end %>
</div>
<div>
<h2>Slack notifications</h2>
<% if @enabled_sailors_logs.any? %>

View File

@@ -33,6 +33,10 @@ Rails.application.configure do
cache_home_stats: {
cron: "0/10 * * * *",
class: "CacheHomeStatsJob"
},
scan_github_repos: {
cron: "0 10 * * *",
class: "ScanGithubReposJob"
}
}
end

View File

@@ -42,6 +42,8 @@ Rails.application.routes.draw do
# Auth routes
get "/auth/slack", to: "sessions#new", as: :slack_auth
get "/auth/slack/callback", to: "sessions#create"
get "/auth/github", to: "sessions#github_new", as: :github_auth
get "/auth/github/callback", to: "sessions#github_create"
post "/auth/email", to: "sessions#email", as: :email_auth
get "/auth/token/:token", to: "sessions#token", as: :auth_token
delete "signout", to: "sessions#destroy", as: "signout"

View File

@@ -0,0 +1,8 @@
class AddGitHubFieldsToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :github_uid, :string
add_column :users, :github_avatar_url, :string
add_column :users, :github_access_token, :text
add_column :users, :github_username, :string
end
end

6
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_19_142656) do
ActiveRecord::Schema[8.0].define(version: 2025_03_19_165910) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -244,6 +244,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_19_142656) do
t.text "slack_access_token"
t.integer "hackatime_extension_text_type", default: 0, null: false
t.string "timezone", default: "UTC"
t.string "github_uid"
t.string "github_avatar_url"
t.text "github_access_token"
t.string "github_username"
t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true
t.index ["timezone"], name: "index_users_on_timezone"
end