Merge pull request #27 from SAP/bug6181

bug6181: Allow wiki login via GitHub credentials
This commit is contained in:
Axel Uhl
2026-05-26 23:12:06 +02:00
committed by GitHub
9 changed files with 429 additions and 99 deletions
@@ -9,5 +9,5 @@ RequiredBy=multi-user.target
[Service]
Type=exec
RemainAfterExit=true
ExecStart=su - wiki -c "cd /home/wiki && rackup -p 4567 /home/wiki/config.ru"
ExecStart=su - wiki -c "cd /home/wiki && ./serve.sh"
ExecStop=/bin/kill -SIGTERM "$MAINPID"
@@ -0,0 +1,32 @@
# frozen_string_literal: true
source "https://rubygems.org"
ruby "3.2.8"
gem "gollum", "5.3.2"
gem "sinatra", "2.2.4"
gem "sinatra-contrib", "2.2.4"
gem "rack", "2.2.21"
gem "rack-protection", "2.2.4"
gem "rack-session", "~> 1.0"
gem "webrick", "1.9.1"
gem "redis", "5.4.1"
gem "redis-store", "1.11.0"
gem "redis-rack", "2.1.2"
gem "rugged", "1.9.0"
gem "github-markup", "4.0.2"
gem "kramdown", "2.5.1"
gem "kramdown-parser-gfm", "1.1.0"
gem "mustache", "1.1.1"
gem "mustache-sinatra", "2.0.0"
gem "rest-client", "2.1.0"
gem 'rufo'
@@ -0,0 +1,24 @@
# Internal Wiki
We use a Ruby based tool called Gollum for our wiki (one wiki to rule them all 🙄).
It provides a nice UI, indexing and versions all changes in Git.
We have added some authentication and access control as a sort of middleware.
This is done via the GitHub OAuth process and some APIs.
## The Process
Users navigate to `/login` (or are automatically redirected here if they are logged out and they try to access a path for modifying a resource) and are redirected to GitHub, starting the OAuth process. They have to authenticate with their chosen GitHub credentials and are then redirected back to the wiki.
If a user tries to read a resource or its history then they can access anything, but they must have push access to the Sailing Analytics repository to modify a resource (e.g. rename, create, modify).
## Installation
Run `bundle install` in the wiki home directory as the user who will start the wiki and make sure the Gemfile is present.
## Files
* The `templates` dir is necessary for adding the logout button. A custom dir can be specified by modifying the `config.ru`.
* The `app.rb` handles the auth and provides an allow list for user paths
* `config.ru` handles configuration and setup as you might guess.
* `Gemfile` all packages and versions.
* `server.sh` launches the wiki via Bundle.
## Running
First ensure the secrets specified at the start of app.rb (`ENV[<name>]`) are exported in a secrets file in the same directory. Then, as the user who owns the git repository that backs the Gollum wiki, run the `serve.sh` script.
@@ -1,99 +1,223 @@
require 'gollum/app'
require 'digest/sha1'
require "gollum/app"
require "digest/sha1"
require "logger"
require "rest-client"
require "rack/session/redis"
require "base64"
#__DIR__ = File.expand_path(File.dirname(__FILE__))
#$: << __DIR__
class App < Precious::App
User = Struct.new(:name, :email, :password_hash, :can_write)
before { authenticate! }
before /edit/ do authorize_write! ; end
before do
session['gollum.author'] = {
:name => "%s" % settings.loggedInUser,
:email => "%s" % settings.loggedInUserEmail,
}
use Rack::Session::Pool, expire_after: 3600, httponly: true #, secure: true
# use Rack::Session::Redis,
# :redis_server => "redis://127.0.0.1:6379/0",
# :expires_in => 3600
LOGGER = Logger.new("/home/wiki/wiki_log.txt")
CLIENT_ID = ENV["CLIENT_ID"]
CLIENT_SECRET = ENV["CLIENT_SECRET"]
REDIRECT_URL = "https://wiki.sapsailing.com/callback"
ACCESS_TOKEN = ENV["ACCESS_TOKEN"]
REPO_OWNER = "SAP"
REPO_NAME = "sailing-analytics"
before { check! }
before "/gollum/(edit|create|rename|delete)/*" do authorize_write end
before do
if session[:email] && session[:name]
session["gollum.author"] = {
:name => session[:name],
:email => session[:email],
}
end
end
get "/logout" do
if session[:access_token]
revoke_access_token(token)
end
session.clear()
"logged out"
end
get "/login" do
session[:oauth_state] = {
:state => SecureRandom.hex(16),
:expiry => Time.now + (60 * 5),
}
params = {
client_id: CLIENT_ID,
scope: "user:email",
state: session[:oauth_state][:state],
redirect_uri: REDIRECT_URL,
}
uri = URI::HTTPS.build(
host: "github.com",
path: "/login/oauth/authorize",
query: URI.encode_www_form(params),
)
redirect uri.to_s()
end
get "/callback" do
if session[:logged_in]
# Ensures cancel works in editor after login.
if session[:prev]
LOGGER.debug(session[:prev])
prev = session[:prev].dup
stripped_prev = prev.sub("/gollum/edit", "")
redirect stripped_prev
end
end
halt 400, "error" if params[:error]
halt 400, "Missing code" unless params[:code]
halt 403, "Old state" unless session[:oauth_state] && session[:oauth_state][:expiry] > Time.now
halt 403, "Invalid OAuth state" unless session[:oauth_state] && session[:oauth_state][:state] == params[:state]
session.delete(:oauth_state)
result = JSON.parse(RestClient.post("https://github.com/login/oauth/access_token",
{
:client_id => CLIENT_ID,
:client_secret => CLIENT_SECRET,
:code => params[:code],
:redirect_uri => REDIRECT_URL,
},
:accept => :json))
scopes = result["scope"].split(",")
if scopes.include?("user:email") && result["access_token"]
access_token = result["access_token"]
fetch_and_set_user_email(access_token)
fetch_and_set_user_name_and_id(access_token)
session[:logged_in] = true
revoke_access_token(access_token)
session.delete(:access_token)
end
if session[:prev]
redirect session[:prev].sub(/\/gollum\/(rename|delete)/, "")
end
redirect "/"
end
helpers do
def authenticate!
public_urls=IO.readlines 'public.txt'
public_urls.each {|url|
if self.env['PATH_INFO'] == url.slice(0, url.length-1)
puts "Allowing " + url
return
end
def public_path?(path)
if path == "/"
return true
end
public_starts = ["/Home", "/wiki", "/favicon.ico"]
public_starts.any? { |link| path.start_with?(link) }
end
if self.env['PATH_INFO'].start_with?('/wiki/images') ||
self.env['PATH_INFO'].start_with?('/favicon.ico')
return
end
}
if self.env['PATH_INFO'].split('/')[1] == 'gollum' && self.env['PATH_INFO'].split('/')[2] == 'assets'
def asset_path?(path)
non_page_patterns = [%r{\A/gollum/(assets|commit|history|last_commit_info).*}, %r{\A/gollum/search}, %r{\A/gollum/latest_changes\z}]
non_page_patterns.any? { |pattern| pattern.match(path) }
end
def auth_path?(path)
auth_paths = [%r{\A/gollum/(edit|create|rename|delete)/.*\z}, %r{\A/gollum/(overview|preview)}, %r{\A/gollum/create}]
auth_paths.any? { |pattern| pattern.match(path) }
end
def login_path?(path)
%r{\A/(login|callback|logout|cancel)\z}.match?(path)
end
def check!
path = env["PATH_INFO"].dup
LOGGER.debug(path)
return if login_path?(path)
return if asset_path?(path)
isPublicPath = public_path?(path)
isAuthPath = auth_path?(path)
if isPublicPath || isAuthPath
session[:prev] = path
return
end
@_auth = Rack::Auth::Basic::Request.new(request.env)
if self.env['PATH_INFO'].split('/').length >=2 &&
(self.env['PATH_INFO'].split('/')[1] != 'wiki' &&
self.env['PATH_INFO'].split('/')[2] != 'wiki' &&
self.env['PATH_INFO'].split('/')[1] != 'home' &&
self.env['PATH_INFO'].split('/')[2] != 'home' &&
self.env['PATH_INFO'].split('/')[1] != 'Home' &&
self.env['PATH_INFO'].split('/')[2] != 'Home' &&
self.env['PATH_INFO'].split('/')[1] != 'Home.md' &&
self.env['PATH_INFO'].split('/')[1] != 'search' &&
self.env['PATH_INFO'].split('/')[2] != 'search' &&
self.env['PATH_INFO'].split('/')[1] != 'edit' &&
self.env['PATH_INFO'].split('/')[2] != 'edit' &&
self.env['PATH_INFO'].split('/')[1] != 'preview' &&
self.env['PATH_INFO'].split('/')[2] != 'preview' &&
(self.env['PATH_INFO'].split('/')[1] != 'gollum' ||
(self.env['PATH_INFO'].split('/')[2] != 'create' &&
(self.env['PATH_INFO'].split('/')[2] != 'overview' || self.env['PATH_INFO'].split('/')[3] != 'wiki'))))
throw(:halt, [403, 'Forbidden - You can not access anything outside wiki/ path.'])
halt 404, "You cannot access anything outside wiki/ path."
end
def authorize_write
LOGGER.debug("Checking auth before writing")
if !session[:logged_in]
if env["PATH_INFO"].dup.match(%r{/gollum/delete/.*})
halt 401, "Unauthorized"
end
redirect "/login"
end
if @_auth.provided?
end
if @_auth.provided? && @_auth.basic? && @_auth.credentials && @user = detected_user(@_auth.credentials)
Precious::App.set(:loggedInUser, @user.name)
Precious::App.set(:loggedInUserEmail, @user.email)
return @user
else
response['WWW-Authenticate'] = %(Basic realm="Gollum Wiki")
throw(:halt, [401, "Not authorized\n"])
halt 403, "Forbidden" unless user_can_write()
end
def user_can_write()
return false unless session[:logged_in] && session[:name]
response = github_api_get("/repos/#{REPO_OWNER}/#{REPO_NAME}/collaborators/#{session[:name]}/permission",
ACCESS_TOKEN)
return false unless response
LOGGER.debug("response received")
result = JSON.parse(response)
LOGGER.debug("checking permission")
result.dig("user", "permissions", "push") == true
end
def fetch_and_set_user_email(access_token)
response = github_api_get("/user/emails", access_token)
return unless response
emails = JSON.parse(response)
if emails[0] && emails[0]["email"]
session[:email] = emails[0]["email"]
LOGGER.debug("email is #{session[:email]}")
end
end
def authorize_write!
throw(:halt, [403, "Forbidden\n"]) unless @user.can_write
end
def users
@_users ||= settings.authorized_users.map {|u| User.new(*u) }
end
def detected_user(credentials)
users.detect do |u|
[u.email, u.password_hash] ==
[credentials[0], Digest::SHA1.hexdigest(credentials[1])]
def fetch_and_set_user_name_and_id(access_token)
response = github_api_get("/user", access_token)
return unless response
user_details = JSON.parse(response)
if user_details["login"] && user_details["id"]
session[:user_id] = user_details["id"]
session[:name] = user_details["login"]
LOGGER.debug("user #{session[:email]} logged in")
end
end
end
def commit_
{
:message => params[:message],
# :name => @user.name,
:email => @user.email
}
def github_api_get(path, access_token)
uri = URI::HTTPS.build(
host: "api.github.com",
path: path,
)
RestClient.get(uri.to_s(),
{
:Authorization => "Bearer #{access_token}",
})
rescue RestClient::Unauthorized, RestClient::Forbidden
LOGGER.warn("GitHub auth failed for #{path}")
nil
rescue RestClient::ExceptionWithResponse => e
LOGGER.error("GitHub API error #{e.response.code} for #{path}")
nil
rescue StandardError => e
LOGGER.error("GitHub request failed: #{e.message}")
nil
end
def revoke_access_token(token)
uri = URI::HTTPS.build(
host: "api.github.com",
path: "/applications/#{CLIENT_ID}/token",
)
basicAuth = Base64.strict_encode64("#{CLIENT_ID}:#{CLIENT_SECRET}")
RestClient::Request.execute(
method: :delete,
url: uri.to_s(),
payload: {
:access_token => token,
}.to_json(),
headers: {
:Authorization => "Basic #{basicAuth}",
:accept => "application/vnd.github.v3+json",
},
)
end
end
end
##set author
#class Precious::App
# before do
# session['gollum.author'] = {
# :name => "%s" % settings.loggedInUser,
# :email => "%s@example.com" % settings.loggedInUser,
# }
# end
#End
@@ -28,11 +28,6 @@
#use Gollum::Auth, users, options
#
## That's it. The rest is for gollum only.
#gollum_path = "/home/wiki/gitwiki"
#wiki_options = {:universal_toc => false}
#Precious::App.set(:gollum_path, gollum_path)
#Precious::App.set(:wiki_options, wiki_options)
#run Precious::App
require 'rubygems'
require 'gollum/app'
@@ -48,7 +43,7 @@ Gollum::Page.send :remove_const, :FORMAT_NAMES if defined? Gollum::Page::FORMAT_
#}
gollum_path = "/home/wiki/gitwiki"
wiki_options = {universal_toc: false, ref: 'main'}
wiki_options = {universal_toc: false, ref: 'main', template_dir: "./templates"}
Precious::App.set(:gollum_path, gollum_path)
Precious::App.set(:wiki_options, wiki_options)
Precious::App.set(:authorized_users, YAML.load_file(File.expand_path('users.yml', File.expand_path(File.dirname(__FILE__)))))
@@ -57,12 +52,3 @@ Precious::App.set(:loggedInUserEmail, "wiki@sapsailing.com");
App.set(:default_markup, :markdown) # set your favorite markup language
run App
#require 'rubygems'
#require 'gollum/app'
#
#gollum_path = "/home/wiki/gitwiki"
#wiki_options = {:universal_toc => false}
#Precious::App.set(:gollum_path, gollum_path)
#Precious::App.set(:default_markup, :markdown) # set your favorite markup language
#Precious::App.set(:wiki_options, wiki_options)
#run Precious::App
@@ -0,0 +1,10 @@
#!/bin/bash
# kill any running gollums
kill -9 `ps axlw | grep rackup | grep wiki | awk '{ print $3; }'`
rm /home/wiki/wiki_log.txt
# start gollum as a background process
# you can pipe output to /dev/null instead, if you don't want a log
cd /home/wiki
. secrets
#nohup rackup -p 4567 /home/wiki/config.ru 1>nohup.out 2>&1 &
nohup bundle exec rackup -p 4567 /home/wiki/config.ru &
@@ -0,0 +1,65 @@
<div id="wiki-content" class="px-2 px-lg-0">
<h1 class="header-title text-center text-md-left pt-4">
{{page_header}}
</h1>
<div class="breadcrumb">{{{breadcrumb}}}</div>
<div class="{{#has_header}}has-header{{/has_header}}{{#has_footer}} has-footer{{/has_footer}}{{#has_sidebar}} has-sidebar has-{{bar_side}}bar{{/has_sidebar}}{{#has_toc}} has-toc{{/has_toc}}">
{{#has_toc}}
<div id="wiki-toc-main">
{{{toc_content}}}
</div>
{{/has_toc}}
<div id="wiki-body" class="gollum-{{format}}-content">
{{#has_header}}
<div id="wiki-header" class="gollum-{{header_format}}-content">
<div id="header-content" class="markdown-body">
{{{header_content}}}
</div>
</div>
{{/has_header}}
<div class="main-content clearfix container-lg">
<div class="markdown-body {{#header_enum?}}header-enum{{/header_enum?}} {{#has_sidebar}}float-md-{{body_side}} col-md-9{{/has_sidebar}}" {{#header_enum?}}style="--header-enum-style:{{header_enum_style}};"{{/header_enum?}}>
{{{rendered_metadata}}}
{{{content}}}
</div>
{{#has_sidebar}}
<div id="wiki-sidebar" class="Box Box--condensed float-md-{{body_side}} col-md-3">
<div id="sidebar-content" class="gollum-{{sidebar_format}}-content markdown-body px-4">
{{{sidebar_content}}}
</div>
</div>
{{/has_sidebar}}
</div>
</div>
{{#has_footer}}
<div id="wiki-footer" class="gollum-{{footer_format}}-content my-2">
<div id="footer-content" class="Box Box-condensed markdown-body px-4">
{{{footer_content}}}
</div>
</div>
{{/has_footer}}
</div>
<div id="footer" class="pt-4">
{{^historical}}
{{^preview}}
<a href="/logout">logout</a>
<p id="last-edit"><div class="dotted-spinner hidden"></div> <a id="page-info-toggle" data-pagepath="{{escaped_url_path}}">When was this page last modified?</a></p>
{{#allow_editing}}
<p>
<a id="delete-link" href="{{escaped_url_path}}" data-confirm="Are you sure you want to delete this page?"><span>Delete this Page</span></a>
</p>
{{/allow_editing}}
{{/preview}}
{{/historical}}
{{#historical}}
<p>This version of the page was edited by <b>{{author}}</b> at <time datetime="{{datetime}}" data-format="{{date_format}}">{{date}}</time>. <a href="{{full_url_path}}">View the most recent version.</a></p>
{{/historical}}
</div>
</div>
@@ -0,0 +1,88 @@
#!/bin/bash
echo "Content-Type: text/html"
echo ""
# Read secrets:
. ~/secrets
# ==== CONFIG for OAuth App wiki.sapsailing.com: ====
CLIENT_ID="${GITHUB_OAUTH_CLIENT_ID}"
CLIENT_SECRET="${GITHUB_OAUTH_CLIENT_SECRET}"
REDIRECT_URI="https://git.sapsailing.com/cgi-bin/github_oauth.sh"
# ===================================================
STATES_FILE=/tmp/github_oauth_states
# Parse QUERY_STRING for code
QUERY="$QUERY_STRING"
CODE=""
STATE_PARAM=""
IFS='&' read -ra KV <<< "$QUERY"
for pair in "${KV[@]}"; do
key="${pair%%=*}"
val="${pair#*=}"
if [ "$key" = "code" ]; then
CODE="$val"
elif [ "$key" = "state" ]; then
STATE_PARAM="$val"
fi
done
# Checking for code parameter; if not found, start the flow
if [ -z "$CODE" ]; then
# No code → show login page
STATE=$(openssl rand -hex 16)
echo "${STATE}" >>"${STATES_FILE}"
AUTH_URL="https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=read:user,user:email&state=${STATE}"
cat <<EOF
<html><head><title>GitHub OAuth Login</title></head>
<body>
<h2>Login with GitHub</h2>
<p><a href="${AUTH_URL}">Authorize this app to access your GitHub account</a></p>
</body></html>
EOF
exit 0
else
if grep -q "${STATE_PARAM}" "${STATES_FILE}"; then
sed -i '/'${STATE_PARAM}'/d' "${STATES_FILE}"
# Got a code → exchange it for an access token
TOKEN_JSON=$(curl -s -X POST https://github.com/login/oauth/access_token \
-H "Accept: application/json" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "code=${CODE}" \
-d "redirect_uri=${REDIRECT_URI}" )
ACCESS_TOKEN=$(echo "$TOKEN_JSON" | jq -r '.access_token')
if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
cat <<EOF
<html><body>
<h3>Failed to obtain access token.</h3>
<pre>${TOKEN_JSON}</pre>
</body></html>
EOF
exit 1
else
# Use token to get user info
USER_JSON=$(curl -s -H "Authorization: token ${ACCESS_TOKEN}" -H "Accept: application/vnd.github+json" https://api.github.com/user)
LOGIN=$(echo "$USER_JSON" | jq -r '.login')
cat <<EOF
<html><head><title>GitHub OAuth Success</title></head>
<body>
<h2>Welcome, ${LOGIN}!</h2>
<p>The state param ${STATE_PARAM} was found in our ${STATES_FILE}:</p>
<pre>$(cat "${STATES_FILE}")</pre>
<p>You have successfully authenticated with GitHub.</p>
<p>Your token JSON was:</p>
<pre>${TOKEN_JSON}</pre>
<p><strong>Access Token:</strong> ${ACCESS_TOKEN}</p>
<p>You can now use this token to make API calls on behalf of this user.</p>
<pre>${USER_JSON}</pre>
</body></html>
EOF
fi
else
# The ${STATE_PARAM} was not found in ${STATES_FILE}, so this may be an attack
cat <<EOF
<html><body>
<h3>Failed to find state; attack?</h3>
<pre>${STATE_PARAM}</pre>
</body></html>
EOF
exit 2
fi
fi
@@ -35,7 +35,7 @@ terminationCheck() {
}
if [[ "$#" -ne 4 ]]; then
echo "4 arguments required. Please check comment description for further details."
echo "Example usage: setup-central-reverse-proxy.sh 1.2.3.4 0OcJ1938QE5it875kjlQe7HnzQ6740jsnMEVzowjZrs= 18.170.25.225 /home/sailing/code"
echo "Example usage: setup-central-reverse-proxy.sh 1.2.3.4 0OcJ1938QE5it875kjlQe7HnzQ6740jsnMEVzowjZrs= 54.229.94.254 /home/wiki/gitwiki"
exit 2
fi
echo "Make sure the instance is in the same AZ as the existing reverse proxy, so the volumes can be switched over."
@@ -65,6 +65,7 @@ yum group install -y "Development Tools"
yum install -y ruby ruby-devel libicu libicu-devel zlib zlib-devel git cmake openssl-devel libyaml-devel
gem install gollum -v 5.3.2
gem update --system 3.5.7
su - wiki -c "cd /home/wiki && bundle install"
cd /home
# copy bugzilla
scp -o StrictHostKeyChecking=no root@sapsailing.com:/var/www/static/bugzilla-5.2.tar.gz /usr/local/src