implemented logout feature

This commit is contained in:
Sebastian Mancke
2017-04-29 10:52:26 +02:00
parent 4abd57d122
commit 80ca91ef4f
5 changed files with 239 additions and 53 deletions
+10
View File
@@ -81,6 +81,10 @@ Returns a simple bootstrap styled login form.
The returned html follows the ui composition conventions from (lib-compose)[https://github.com/tarent/lib-compose],
so it can be embedded into an existing layout.
### GET /login/<provider>
Starts the Oauth Web Flow with the configured provider. E.g. `GET /login/github` redirects to the github login form.
### POST /login
Does the login and returns the JWT. Depending on the content-type, and parameters a classical JSON-Rest or a redirect can be performed.
@@ -108,6 +112,12 @@ Does the login and returns the JWT. Depending on the content-type, and parameter
Hint: The status `401 Unauthorized` is not used as a return code to not conflict with an Http BasicAuth Authentication.
### DELETE /login
Deletes the JWT Cookie.
For simple usage in web applications, this can also be called by `GET|POST /login?logout=true`
#### Example:
Default is to return the token as Content-Type application/jwt within the body.
```
+63 -19
View File
@@ -10,6 +10,7 @@ import (
"io/ioutil"
"net/http"
"strings"
"time"
)
const contentTypeHtml = "text/html; charset=utf-8"
@@ -97,7 +98,7 @@ func (h *Handler) handleOauth(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
if !(r.Method == "GET" ||
if !(r.Method == "GET" || r.Method == "DELETE" ||
(r.Method == "POST" &&
(strings.HasPrefix(contentType, "application/json") ||
strings.HasPrefix(contentType, "application/x-www-form-urlencoded") ||
@@ -107,11 +108,24 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
}
r.ParseForm()
if r.Method == "GET" {
if r.Method == "DELETE" || r.FormValue("logout") == "true" {
h.deleteToken(w)
writeLoginForm(w,
map[string]interface{}{
"path": r.URL.Path,
"config": h.config,
loginFormData{
Path: r.URL.Path,
Config: h.config,
})
return
}
if r.Method == "GET" {
userInfo, valid := h.getToken(r)
writeLoginForm(w,
loginFormData{
Path: r.URL.Path,
Config: h.config,
Authenticated: valid,
UserInfo: userInfo,
})
return
}
@@ -143,6 +157,17 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
}
}
func (h *Handler) deleteToken(w http.ResponseWriter) {
cookie := &http.Cookie{
Name: h.config.CookieName,
Value: "delete",
HttpOnly: true,
Expires: time.Unix(0, 0),
Path: "/",
}
http.SetCookie(w, cookie)
}
func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, userInfo jwt.Claims) {
token, err := h.createToken(userInfo)
if err != nil {
@@ -152,7 +177,12 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u
}
if wantHtml(r) {
// TODO: set livetime
cookie := &http.Cookie{Name: h.config.CookieName, Value: token, HttpOnly: true}
cookie := &http.Cookie{
Name: h.config.CookieName,
Value: token,
HttpOnly: true,
Path: "/",
}
http.SetCookie(w, cookie)
w.Header().Set("Location", h.config.SuccessUrl)
w.WriteHeader(303)
@@ -169,17 +199,32 @@ func (h *Handler) createToken(userInfo jwt.Claims) (string, error) {
return token.SignedString([]byte(h.config.JwtSecret))
}
func (h *Handler) getToken(r *http.Request) (userInfo UserInfo, valid bool) {
c, err := r.Cookie(h.config.CookieName)
if err != nil {
return UserInfo{}, false
}
token, err := jwt.ParseWithClaims(c.Value, &UserInfo{}, func(*jwt.Token) (interface{}, error) {
return []byte(h.config.JwtSecret), nil
})
if err != nil {
return UserInfo{}, false
}
u, v := token.Claims.(*UserInfo)
return *u, v
}
func (h *Handler) respondError(w http.ResponseWriter, r *http.Request) {
if wantHtml(r) {
w.Header().Set("Content-Type", contentTypeHtml)
w.WriteHeader(500)
username, _, _ := getCredentials(r)
writeLoginForm(w,
map[string]interface{}{
"path": r.URL.Path,
"error": true,
"config": h.config,
"username": username,
loginFormData{
Path: r.URL.Path,
Error: true,
Config: h.config,
UserInfo: UserInfo{Username: username},
})
return
}
@@ -199,12 +244,11 @@ func (h *Handler) respondAuthFailure(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(403)
username, _, _ := getCredentials(r)
writeLoginForm(w,
map[string]interface{}{
"path": r.URL.Path,
"failure": true,
"config": h.config,
"username": username,
loginFormData{
Path: r.URL.Path,
Failure: true,
Config: h.config,
UserInfo: UserInfo{Username: username},
})
return
}
+63
View File
@@ -109,6 +109,7 @@ func TestHandler_LoginWeb(t *testing.T) {
claims, err := tokenAsMap(strings.SplitN(headerParts[1], ";", 2)[0])
assert.NoError(t, err)
assert.Equal(t, map[string]interface{}{"sub": "bob"}, claims)
assert.Contains(t, headerParts[1]+";", "Path=/;")
// show the login form again after authentication failed
recorder = call(req("POST", "/context/login", "username=bob&password=FOOBAR", TypeForm, AcceptHtml))
@@ -119,6 +120,23 @@ func TestHandler_LoginWeb(t *testing.T) {
assert.Equal(t, recorder.Header().Get("Set-Cookie"), "")
}
func TestHandler_Logout(t *testing.T) {
// DELETE
recorder := call(req("DELETE", "/context/login", ""))
assert.Equal(t, 200, recorder.Code)
assert.Contains(t, recorder.Header().Get("Set-Cookie"), "jwt_token=delete; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;")
// GET + param
recorder = call(req("GET", "/context/login?logout=true", ""))
assert.Equal(t, 200, recorder.Code)
assert.Contains(t, recorder.Header().Get("Set-Cookie"), "jwt_token=delete; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;")
// POST + param
recorder = call(req("POST", "/context/login", "logout=true", TypeForm))
assert.Equal(t, 200, recorder.Code)
assert.Contains(t, recorder.Header().Get("Set-Cookie"), "jwt_token=delete; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;")
}
func TestHandler_LoginError(t *testing.T) {
h := testHandlerWithError()
@@ -142,6 +160,51 @@ func TestHandler_LoginError(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "Internal Error")
}
func TestHandler_getToken_Valid(t *testing.T) {
h := testHandler()
input := UserInfo{Username: "marvin"}
token, err := h.createToken(input)
assert.NoError(t, err)
r := &http.Request{
Header: http.Header{"Cookie": {h.config.CookieName + "=" + token + ";"}},
}
userInfo, valid := h.getToken(r)
assert.True(t, valid)
assert.Equal(t, input, userInfo)
}
func TestHandler_getToken_InvalidSecret(t *testing.T) {
h := testHandler()
input := UserInfo{Username: "marvin"}
token, err := h.createToken(input)
assert.NoError(t, err)
r := &http.Request{
Header: http.Header{"Cookie": {h.config.CookieName + "=" + token + ";"}},
}
// modify secret
h.config.JwtSecret = "foobar"
_, valid := h.getToken(r)
assert.False(t, valid)
}
func TestHandler_getToken_InvalidToken(t *testing.T) {
h := testHandler()
r := &http.Request{
Header: http.Header{"Cookie": {h.config.CookieName + "=asdcsadcsadc"}},
}
_, valid := h.getToken(r)
assert.False(t, valid)
}
func TestHandler_getToken_InvalidNoToken(t *testing.T) {
h := testHandler()
_, valid := h.getToken(&http.Request{})
assert.False(t, valid)
}
func testHandler() *Handler {
return &Handler{
backends: []Backend{
+102 -33
View File
@@ -1,54 +1,123 @@
package login
import (
"bytes"
"github.com/tarent/lib-compose/logging"
"html/template"
"io"
"net/http"
)
const loginForm = `<!DOCTYPE html>
<html>
<head>
<link uic-remove rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<link uic-remove rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-social/5.1.1/bootstrap-social.min.css">
<link uic-remove rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css">
<style>
.vertical-offset-100{
padding-top:100px;
}
.vertical-offset-100{
padding-top:100px;
}
.login-or-container {
text-align: center;
margin: 0;
margin-bottom: 10px;
clear: both;
color: #6a737c;
font-variant: small-caps;
}
.login-or-hr {
margin-bottom: 0;
position: relative;
top: 28px;
height: 0;
border: 0;
border-top: 1px solid #e4e6e8;
}
.login-or {
display: inline-block;
position: relative;
padding: 10px;
background-color: #FFF;
}
</style>
</head>
<body>
<uic-fragment name="content">
<div class="container">
<div class="row vertical-offset-100">
<div class="col-md-4 col-md-offset-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Please sign in</h3>
{{ if .error}}Internal Error. Please try again later{{end}}
{{ if .failure}}Wrong credentials{{end}}
</div>
<div class="panel-body">
<form accept-charset="UTF-8" role="form" method="POST" action="{{.path}}">
<fieldset>
<div class="form-group">
<input class="form-control" placeholder="Username" name="username" value="{{.username}}" type="text">
</div>
<div class="form-group">
<input class="form-control" placeholder="Password" name="password" type="password" value="">
</div>
<input class="btn btn-lg btn-success btn-block" type="submit" value="Login">
</fieldset>
</form>
</div>
</div>
</div>
<div class="container">
<div class="row vertical-offset-100">
<div class="col-md-4 col-md-offset-4">
{{ if .Error}}
<div class="alert alert-danger" role="alert">
<strong>Internal Error. </strong> Please try again later.
</div>
{{end}}
{{ if .Authenticated}}
<h3>Welcome {{.UserInfo.Username}}</h3>
<a href="login?logout=true">Logout</a>
{{else}}
<a class="btn btn-block btn-lg btn-social btn-github" href="login/github">
<span class="fa fa-github"></span> Sign in with Github
</a>
<div class="login-or-container">
<hr class="login-or-hr">
<div class="login-or lead">or</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<div class="panel-title">
<h4>Sign in</h4>
{{ if .Failure}}<div class="alert alert-warning" role="alert">Invalid credentials</div>{{end}}
</div>
</div>
<div class="panel-body">
<form accept-charset="UTF-8" role="form" method="POST" action="{{.Path}}">
<fieldset>
<div class="form-group">
<input class="form-control" placeholder="Username" name="username" value="{{.UserInfo.Username}}" type="text">
</div>
<div class="form-group">
<input class="form-control" placeholder="Password" name="password" type="password" value="">
</div>
<input class="btn btn-lg btn-success btn-block" type="submit" value="Login">
</fieldset>
</form>
</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
</uic-fragment>
</body>
</html>
`
</html>`
func writeLoginForm(w io.Writer, params map[string]interface{}) {
type loginFormData struct {
Path string
Error bool
Failure bool
Config *Config
Authenticated bool
UserInfo UserInfo
}
func writeLoginForm(w http.ResponseWriter, params loginFormData) {
t := template.Must(template.New("loginForm").Parse(loginForm))
t.Execute(w, params)
b := bytes.NewBuffer(nil)
err := t.Execute(b, params)
if err != nil {
logging.Logger.WithError(err).Error()
w.WriteHeader(500)
w.Write([]byte(`Internal Server Error`))
return
}
w.Header().Set("Content-Type", contentTypeHtml)
if params.Error {
w.WriteHeader(500)
}
w.Write(b.Bytes())
}
+1 -1
View File
@@ -13,6 +13,6 @@ var providerGithub = Provider{
GetUserInfo: func(token TokenInfo) (map[string]string, error) {
//http.Get(fmt.Sprintf("%v/user?access_token=%v"), githubApi, token.AccessToken)
// https://developer.github.com/v3/users/
return nil, nil
return map[string]string{"username": "demo"}, nil
},
}