1
0
mirror of https://github.com/Luzifer/vault-otp-ui.git synced 2024-09-16 15:48:32 +00:00

Initial version

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2017-06-14 20:40:12 +02:00
commit 154562e961
Signed by: luzifer
GPG Key ID: DC2729FDD34BE99E
9 changed files with 966 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
vault-otp-ui

6
Makefile Normal file
View File

@ -0,0 +1,6 @@
generate: build_js
go generate
build_js:
coffee -c application.coffee

69
application.coffee Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

130
index.html Normal file
View 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
View 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
View 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
View 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
}