mirror of
https://github.com/Luzifer/vault-otp-ui.git
synced 2024-11-09 16:50:05 +00:00
Initial version
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
commit
154562e961
9 changed files with 966 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
.env
|
||||||
|
vault-otp-ui
|
6
Makefile
Normal file
6
Makefile
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
generate: build_js
|
||||||
|
go generate
|
||||||
|
|
||||||
|
build_js:
|
||||||
|
coffee -c application.coffee
|
69
application.coffee
Normal file
69
application.coffee
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
currentTimeout = 0
|
||||||
|
clipboard = undefined
|
||||||
|
|
||||||
|
$ ->
|
||||||
|
if $('body').hasClass 'state-signedin'
|
||||||
|
initializeApplication()
|
||||||
|
|
||||||
|
createOTPItem = (item) ->
|
||||||
|
tpl = $('#otp-item').html()
|
||||||
|
|
||||||
|
otpItem = $(tpl)
|
||||||
|
otpItem.find('.badge').text item.code.replace(/^(.{3})(.{3})$/, '$1 $2')
|
||||||
|
otpItem.find('.title').text item.name
|
||||||
|
|
||||||
|
otpItem.appendTo $('#keylist')
|
||||||
|
|
||||||
|
delay = (delayMSecs, fkt) ->
|
||||||
|
window.setTimeout fkt, delayMSecs
|
||||||
|
|
||||||
|
fetchCodes = () ->
|
||||||
|
$.ajax
|
||||||
|
url: 'codes.json',
|
||||||
|
success: updateCodes,
|
||||||
|
dataType: 'json',
|
||||||
|
|
||||||
|
filterChange = () ->
|
||||||
|
filter = $('#filter').val().toLowerCase()
|
||||||
|
$('.otp-item').each (idx, el) ->
|
||||||
|
if $(el).find('.title').text().toLowerCase().match(filter) == null
|
||||||
|
$(el).hide()
|
||||||
|
else
|
||||||
|
$(el).show()
|
||||||
|
|
||||||
|
initializeApplication = () ->
|
||||||
|
$('#keylist').empty()
|
||||||
|
$('#filter').bind 'keyup', filterChange
|
||||||
|
tick 500, refreshTimerProgress
|
||||||
|
fetchCodes()
|
||||||
|
|
||||||
|
# refreshTimerProgress updates the top progressbar to display the
|
||||||
|
# remaining time until the one-time-passwords changes
|
||||||
|
refreshTimerProgress = () ->
|
||||||
|
secondsLeft = timeLeft()
|
||||||
|
$('#timer').css 'width', "#{secondsLeft / 30 * 100}%"
|
||||||
|
|
||||||
|
tick = (delay, fkt) ->
|
||||||
|
window.setInterval fkt, delay
|
||||||
|
|
||||||
|
timeLeft = () ->
|
||||||
|
now = new Date().getTime()
|
||||||
|
(currentTimeout - now) / 1000
|
||||||
|
|
||||||
|
updateCodes = (data) ->
|
||||||
|
currentTimeout = new Date(data.next_wrap).getTime()
|
||||||
|
|
||||||
|
if clipboard
|
||||||
|
clipboard.destroy()
|
||||||
|
|
||||||
|
$('#keylist').empty()
|
||||||
|
for token in data.tokens
|
||||||
|
createOTPItem token
|
||||||
|
|
||||||
|
clipboard = new Clipboard '.otp-item',
|
||||||
|
text: (trigger) ->
|
||||||
|
$(trigger).find('.badge').text().replace(' ', '')
|
||||||
|
|
||||||
|
filterChange()
|
||||||
|
|
||||||
|
delay timeLeft()*1000, fetchCodes
|
92
application.js
Normal file
92
application.js
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
// Generated by CoffeeScript 1.12.4
|
||||||
|
(function() {
|
||||||
|
var clipboard, createOTPItem, currentTimeout, delay, fetchCodes, filterChange, initializeApplication, refreshTimerProgress, tick, timeLeft, updateCodes;
|
||||||
|
|
||||||
|
currentTimeout = 0;
|
||||||
|
|
||||||
|
clipboard = void 0;
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
if ($('body').hasClass('state-signedin')) {
|
||||||
|
return initializeApplication();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createOTPItem = function(item) {
|
||||||
|
var otpItem, tpl;
|
||||||
|
tpl = $('#otp-item').html();
|
||||||
|
otpItem = $(tpl);
|
||||||
|
otpItem.find('.badge').text(item.code.replace(/^(.{3})(.{3})$/, '$1 $2'));
|
||||||
|
otpItem.find('.title').text(item.name);
|
||||||
|
return otpItem.appendTo($('#keylist'));
|
||||||
|
};
|
||||||
|
|
||||||
|
delay = function(delayMSecs, fkt) {
|
||||||
|
return window.setTimeout(fkt, delayMSecs);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCodes = function() {
|
||||||
|
return $.ajax({
|
||||||
|
url: 'codes.json',
|
||||||
|
success: updateCodes,
|
||||||
|
dataType: 'json'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
filterChange = function() {
|
||||||
|
var filter;
|
||||||
|
filter = $('#filter').val().toLowerCase();
|
||||||
|
return $('.otp-item').each(function(idx, el) {
|
||||||
|
if ($(el).find('.title').text().toLowerCase().match(filter) === null) {
|
||||||
|
return $(el).hide();
|
||||||
|
} else {
|
||||||
|
return $(el).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeApplication = function() {
|
||||||
|
$('#keylist').empty();
|
||||||
|
$('#filter').bind('keyup', filterChange);
|
||||||
|
tick(500, refreshTimerProgress);
|
||||||
|
return fetchCodes();
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshTimerProgress = function() {
|
||||||
|
var secondsLeft;
|
||||||
|
secondsLeft = timeLeft();
|
||||||
|
return $('#timer').css('width', (secondsLeft / 30 * 100) + "%");
|
||||||
|
};
|
||||||
|
|
||||||
|
tick = function(delay, fkt) {
|
||||||
|
return window.setInterval(fkt, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
timeLeft = function() {
|
||||||
|
var now;
|
||||||
|
now = new Date().getTime();
|
||||||
|
return (currentTimeout - now) / 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCodes = function(data) {
|
||||||
|
var i, len, ref, token;
|
||||||
|
currentTimeout = new Date(data.next_wrap).getTime();
|
||||||
|
if (clipboard) {
|
||||||
|
clipboard.destroy();
|
||||||
|
}
|
||||||
|
$('#keylist').empty();
|
||||||
|
ref = data.tokens;
|
||||||
|
for (i = 0, len = ref.length; i < len; i++) {
|
||||||
|
token = ref[i];
|
||||||
|
createOTPItem(token);
|
||||||
|
}
|
||||||
|
clipboard = new Clipboard('.otp-item', {
|
||||||
|
text: function(trigger) {
|
||||||
|
return $(trigger).find('.badge').text().replace(' ', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
filterChange();
|
||||||
|
return delay(timeLeft() * 1000, fetchCodes);
|
||||||
|
};
|
||||||
|
|
||||||
|
}).call(this);
|
258
assets.go
Normal file
258
assets.go
Normal file
File diff suppressed because one or more lines are too long
130
index.html
Normal file
130
index.html
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
|
||||||
|
<title>Vault OTP-UI</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap-theme.min.css"
|
||||||
|
integrity="sha256-ZT4HPpdCOt2lvDkXokHuhJfdOKSPFLzeAJik5U/Q+l4=" crossorigin="anonymous" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootswatch/3.3.7/paper/bootstrap.min.css"
|
||||||
|
integrity="sha256-LxKiHTQko0DUCUSgrIK23SYMymvfuj8uxXmblBvVWm0=" crossorigin="anonymous" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||||
|
integrity="sha256-eZrrJcwDc/3uDhsdt61sL2oOBY362qM3lon1gyExkL0=" crossorigin="anonymous" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { font-size: 16px; padding-top: 90px; }
|
||||||
|
i { margin-right: 0.4em; }
|
||||||
|
#templates { display: none; }
|
||||||
|
.badge { background-color: #e2e2e2; color: #555; font-size: 15px; font-weight: bold; margin-top: 3px; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
.jumbotron h2 { text-align: center; }
|
||||||
|
.otp-item i { width: 1.1em; }
|
||||||
|
.pbar { background-color: #2196f3; height: 100%; }
|
||||||
|
.pcontainer { background-color: transparent; height: 1px; position: absolute; bottom: 0; left: 0; width: 100%; z-index: 999; }
|
||||||
|
.state-signedin #login { display: none; }
|
||||||
|
.state-signedout #application { display: none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
|
||||||
|
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||||
|
<!--[if lt IE 9]>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"
|
||||||
|
integrity="sha256-3Jy/GbSLrg0o9y5Z5n1uw0qxZECH7C6OQpVBgNFYa0g=" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/respond.js/1.4.2/respond.min.js"
|
||||||
|
integrity="sha256-g6iAfvZp+nDQ2TdTR/VVKJf3bGro4ub5fvWSWVRi2NE=" crossorigin="anonymous"></script>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body class="state-{{ if .isloggedin }}signedin{{ else }}signedout{{ end }}">
|
||||||
|
<nav class="navbar navbar-fixed-top navbar-default">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="navbar-header">
|
||||||
|
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
|
||||||
|
<span class="sr-only">Toggle navigation</span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
<span class="icon-bar"></span>
|
||||||
|
</button>
|
||||||
|
<a class="navbar-brand" href="#">Vault OTP-UI</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
|
||||||
|
<form class="navbar-form navbar-left">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-control" placeholder="Filter" id="filter">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ul class="nav navbar-nav navbar-right">
|
||||||
|
<li><a href="https://github.com/Luzifer/vault-otp-ui"><i class="fa fa-github" aria-hidden="true"></i> Source on Github</a></li>
|
||||||
|
</ul>
|
||||||
|
</div><!-- /.navbar-collapse -->
|
||||||
|
</div><!-- /.container-fluid -->
|
||||||
|
|
||||||
|
<div class="pcontainer">
|
||||||
|
<div class="pbar" style="width: 0%;" id="timer"></div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="application">
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-12 col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3">
|
||||||
|
<div class="list-group" id="keylist">
|
||||||
|
<!-- FIXME: Remove this -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- /#application -->
|
||||||
|
|
||||||
|
<div id="login">
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-12 col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3">
|
||||||
|
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Please sign in!</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<p>
|
||||||
|
Use Github authentication to sign into your Vault instance and get access to your one-time passwords:
|
||||||
|
</p>
|
||||||
|
<p class="center">
|
||||||
|
<a href="{{ .authurl }}" class="btn btn-primary"><i class="fa fa-github" aria-hidden="true"></i> Sign-in with Github</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- /#login -->
|
||||||
|
|
||||||
|
<div id="templates">
|
||||||
|
<div id="otp-item">
|
||||||
|
<a href="#" class="list-group-item otp-item">
|
||||||
|
<span class="badge">145 369</span>
|
||||||
|
<i class="fa fa-key"></i>
|
||||||
|
<span class="title">Some Site</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.min.js"
|
||||||
|
integrity="sha256-ZosEbRLbNQzLpnKIkEdrPv7lOy9C27hHQ+Xp8a4MxAQ=" crossorigin="anonymous"></script>
|
||||||
|
<!-- Include all compiled plugins (below), or include individual files as needed -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js"
|
||||||
|
integrity="sha256-U5ZEeKfGNOja007MMD3YBI0A3OSZOQbeG6z2f2Y0hu8=" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/1.7.1/clipboard.min.js"
|
||||||
|
integrity="sha256-Daf8GuI2eLKHJlOWLRR/zRy9Clqcj4TUSumbxYH9kGI=" crossorigin="anonymous"></script>
|
||||||
|
<!-- Application specific JavaScript -->
|
||||||
|
<script src="application.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
209
main.go
Normal file
209
main.go
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
//go:generate go-bindata -pkg $GOPACKAGE -o assets.go index.html application.js
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Luzifer/rconfig"
|
||||||
|
log "github.com/Sirupsen/logrus"
|
||||||
|
"github.com/alecthomas/template"
|
||||||
|
"github.com/gorilla/securecookie"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/tdewolff/minify"
|
||||||
|
"github.com/tdewolff/minify/html"
|
||||||
|
"github.com/tdewolff/minify/js"
|
||||||
|
validator "gopkg.in/validator.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
requiredScope = "read:org,user"
|
||||||
|
sessionName = "vault-otp-ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg struct {
|
||||||
|
Github struct {
|
||||||
|
ClientID string `flag:"client-id" default:"" env:"CLIENT_ID" description:"Github oAuth2 application Client ID" validate:"nonzero"`
|
||||||
|
ClientSecret string `flag:"client-secret" default:"" env:"CLIENT_SECRET" description:"Github oAuth2 application Client Secret" validate:"nonzero"`
|
||||||
|
}
|
||||||
|
Listen string `flag:"listen" default:":3000" description:"IP/Port to listen on"`
|
||||||
|
LogLevel string `flag:"log-level" default:"info" description:"Set log level (debug, info, warning, error)"`
|
||||||
|
SessionSecret string `flag:"session-secret" default:"" env:"SESSION_SECRET" description:"Secret to encrypt the session with"`
|
||||||
|
Vault struct {
|
||||||
|
Address string `flag:"vault-addr" env:"VAULT_ADDR" default:"https://127.0.0.1:8200" description:"Vault API address"`
|
||||||
|
Prefix string `flag:"vault-prefix" env:"VAULT_PREFIX" default:"/totp" description:"Prefix to search for OTP secrets / tokens in"`
|
||||||
|
SecretField string `flag:"vault-secret-field" env:"VAULT_SECRET_FIELD" default:"secret" description:"Field to search the secret in"`
|
||||||
|
}
|
||||||
|
VersionAndExit bool `flag:"version" default:"false" description:"Print version information and exit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
version = "dev"
|
||||||
|
mini = minify.New()
|
||||||
|
cookieStore *sessions.CookieStore
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadConfig() error {
|
||||||
|
if err := rconfig.Parse(&cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.Validate(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if l, err := log.ParseLevel(cfg.LogLevel); err == nil {
|
||||||
|
log.SetLevel(l)
|
||||||
|
} else {
|
||||||
|
log.Fatalf("Invalid log level: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VersionAndExit {
|
||||||
|
fmt.Printf("vault-otp-ui %s\n", version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SessionSecret == "" {
|
||||||
|
cookieStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64), securecookie.GenerateRandomKey(32))
|
||||||
|
} else {
|
||||||
|
cookieStore = sessions.NewCookieStore([]byte(cfg.SessionSecret), []byte(fmt.Sprintf("%x", sha1.Sum([]byte(cfg.SessionSecret)))[0:32]))
|
||||||
|
}
|
||||||
|
|
||||||
|
mini.AddFunc("text/html", html.Minify)
|
||||||
|
mini.AddFunc("application/javascript", js.Minify)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error
|
||||||
|
if err = loadConfig(); err != nil {
|
||||||
|
log.Fatalf("Unable to parse CLI parameters: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/oauth2", handleOAuthCallback)
|
||||||
|
http.HandleFunc("/application.js", handleApplicationJS)
|
||||||
|
http.HandleFunc("/codes.json", handleCodesJSON)
|
||||||
|
http.HandleFunc("/", handleIndexPage)
|
||||||
|
log.Fatalf("HTTP server exitted: %s", http.ListenAndServe(cfg.Listen, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFileContentFallback(filename string) (io.Reader, error) {
|
||||||
|
if f, err := os.Open(filename); err == nil {
|
||||||
|
defer f.Close()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
io.Copy(buf, f)
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if b, err := Asset(filename); err == nil {
|
||||||
|
return bytes.NewReader(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("No suitable index page found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIndexPage(res http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, _ := cookieStore.Get(r, sessionName)
|
||||||
|
_, hasAccessToken := sess.Values["access_token"]
|
||||||
|
|
||||||
|
content, err := getFileContentFallback("index.html")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(res, "No suitable index page found", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
io.Copy(buf, content)
|
||||||
|
|
||||||
|
tpl, err := template.New("index").Parse(buf.String())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Parsing index template failed: %s", err)
|
||||||
|
http.Error(res, "No suitable index page found", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outbuf := new(bytes.Buffer)
|
||||||
|
tpl.Execute(outbuf, map[string]interface{}{
|
||||||
|
"isloggedin": hasAccessToken,
|
||||||
|
"authurl": getAuthenticationURL(),
|
||||||
|
})
|
||||||
|
|
||||||
|
mini.Minify("text/html", res, outbuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleApplicationJS(res http.ResponseWriter, r *http.Request) {
|
||||||
|
content, err := getFileContentFallback("application.js")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(res, "No suitable file found", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
mini.Minify("application/javascript", res, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOAuthCallback(res http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, _ := cookieStore.Get(r, sessionName)
|
||||||
|
|
||||||
|
accessToken, err := getAccessToken(r.URL.Query().Get("code"))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("An error occurred while fetching the access token: %s", err)
|
||||||
|
http.Error(res, "Something went wrong when fetching your access token. Sorry.", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if accessToken == "" {
|
||||||
|
log.Errorf("Code %q was not resolved to an access token", r.URL.Query().Get("code"))
|
||||||
|
http.Error(res, "Something went wrong when fetching your access token. Sorry.", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Values["access_token"] = accessToken
|
||||||
|
if err := sess.Save(r, res); err != nil {
|
||||||
|
log.Errorf("Was not able to set the cookie: %s", err)
|
||||||
|
http.Error(res, "Something went wrong when fetching your access token. Sorry.", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(res, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCodesJSON(res http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, _ := cookieStore.Get(r, sessionName)
|
||||||
|
iAccessToken, hasAccessToken := sess.Values["access_token"]
|
||||||
|
|
||||||
|
if !hasAccessToken {
|
||||||
|
http.Error(res, `{"error":"Not logged in"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := iAccessToken.(string)
|
||||||
|
|
||||||
|
tokens, err := getSecretsFromVault(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to fetch codes: %s", err)
|
||||||
|
http.Error(res, `{"error":"Unexpected error while fetching tokens"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n := time.Now()
|
||||||
|
result := struct {
|
||||||
|
Tokens []*token `json:"tokens"`
|
||||||
|
NextWrap time.Time `json:"next_wrap"`
|
||||||
|
}{
|
||||||
|
Tokens: tokens,
|
||||||
|
NextWrap: n.Add(time.Duration(30-(n.Second()%30)) * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Header().Set("Content-Type", "application/json")
|
||||||
|
res.Header().Set("Cache-Control", "no-cache")
|
||||||
|
json.NewEncoder(res).Encode(result)
|
||||||
|
}
|
34
oauth.go
Normal file
34
oauth.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getAuthenticationURL() string {
|
||||||
|
return fmt.Sprintf("http://github.com/login/oauth/authorize?client_id=%s&scope=read:org user", cfg.Github.ClientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccessToken(code string) (string, error) {
|
||||||
|
v := url.Values{}
|
||||||
|
|
||||||
|
v.Set("client_id", cfg.Github.ClientID)
|
||||||
|
v.Set("client_secret", cfg.Github.ClientSecret)
|
||||||
|
v.Set("code", code)
|
||||||
|
|
||||||
|
buf := bytes.NewReader([]byte(v.Encode()))
|
||||||
|
req, _ := http.NewRequest("POST", "https://github.com/login/oauth/access_token", buf)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
r := map[string]string{}
|
||||||
|
return r["access_token"], json.NewDecoder(resp.Body).Decode(&r)
|
||||||
|
}
|
166
token.go
Normal file
166
token.go
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/api"
|
||||||
|
"github.com/hashicorp/vault/builtin/credential/github"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"github.com/prometheus/common/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type token struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Secret string `json:"-"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *token) GenerateCode(in time.Time) error {
|
||||||
|
secret := t.Secret
|
||||||
|
|
||||||
|
if n := len(secret) % 8; n != 0 {
|
||||||
|
secret = secret + strings.Repeat("=", 8-n)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
t.Code, err = totp.GenerateCode(strings.ToUpper(secret), in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorter interface
|
||||||
|
|
||||||
|
type tokenList []*token
|
||||||
|
|
||||||
|
func (t tokenList) Len() int { return len(t) }
|
||||||
|
func (t tokenList) Less(i, j int) bool { return strings.ToLower(t[i].Name) < strings.ToLower(t[j].Name) }
|
||||||
|
func (t tokenList) Swap(i, j int) { t[i], t[j] = t[j], t[i] }
|
||||||
|
|
||||||
|
func (t tokenList) LongestName() (l int) {
|
||||||
|
for _, s := range t {
|
||||||
|
if ll := len(s.Name); ll > l {
|
||||||
|
l = ll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSecretsFromVault(accessToken string) ([]*token, error) {
|
||||||
|
client, err := api.NewClient(&api.Config{
|
||||||
|
Address: cfg.Vault.Address,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Unable to create client: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := &github.CLIHandler{}
|
||||||
|
t, err := handler.Auth(client, map[string]string{"token": accessToken})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.SetToken(t)
|
||||||
|
|
||||||
|
key := cfg.Vault.Prefix
|
||||||
|
|
||||||
|
resp := []*token{}
|
||||||
|
respChan := make(chan *token, 100)
|
||||||
|
|
||||||
|
keyPoolChan := make(chan string, 100)
|
||||||
|
|
||||||
|
scanPool := make(chan string, 100)
|
||||||
|
scanPool <- strings.TrimRight(key, "*")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
defer func() { done <- struct{}{} }()
|
||||||
|
|
||||||
|
wg := new(sync.WaitGroup)
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case key := <-scanPool:
|
||||||
|
go scanKeyForSubKeys(client, key, scanPool, keyPoolChan, wg)
|
||||||
|
case key := <-keyPoolChan:
|
||||||
|
go fetchTokenFromKey(client, key, respChan, wg)
|
||||||
|
case t := <-respChan:
|
||||||
|
resp = append(resp, t)
|
||||||
|
case <-done:
|
||||||
|
close(scanPool)
|
||||||
|
close(keyPoolChan)
|
||||||
|
close(respChan)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
sort.Sort(tokenList(resp))
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanKeyForSubKeys(client *api.Client, key string, subKeyChan, tokenKeyChan chan string, wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
s, err := client.Logical().List(key)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to list keys %q: %s", key, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == nil {
|
||||||
|
log.Errorf("There is no key %q", key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Data["keys"] != nil {
|
||||||
|
for _, sk := range s.Data["keys"].([]interface{}) {
|
||||||
|
sks := sk.(string)
|
||||||
|
if strings.HasSuffix(sks, "/") {
|
||||||
|
wg.Add(1)
|
||||||
|
subKeyChan <- path.Join(key, sks)
|
||||||
|
} else {
|
||||||
|
wg.Add(1)
|
||||||
|
tokenKeyChan <- path.Join(key, sks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTokenFromKey(client *api.Client, k string, respChan chan *token, wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
data, err := client.Logical().Read(k)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Unable to read from key %q: %s", k, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tok := &token{}
|
||||||
|
|
||||||
|
if data.Data[cfg.Vault.SecretField] != nil {
|
||||||
|
tok.Secret = data.Data[cfg.Vault.SecretField].(string)
|
||||||
|
tok.GenerateCode(time.Now())
|
||||||
|
} else if data.Data["code"] != nil {
|
||||||
|
tok.Code = data.Data["code"].(string)
|
||||||
|
} else {
|
||||||
|
// Secret did not have our field or a code, looks bad
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tok.Name = k
|
||||||
|
if data.Data["name"] != nil {
|
||||||
|
tok.Name = data.Data["name"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
respChan <- tok
|
||||||
|
}
|
Loading…
Reference in a new issue