implement recaptcha on login

Former-commit-id: d7495b6fff4a99a8d155a3be87b15535a74a1305 [formerly 5b3a544447cca0d1cdcb6c87ca94f450a5493506] [formerly b4de1a4f5d4dd295c98366ede2b87bf2cb7918f9 [formerly 002f8066c7]]
Former-commit-id: c0e5d38111a99f8e3e71fb5db86e19b7ba44ec48 [formerly 1b5e454263ba64ced95c6d4b51f5f32e66f74758]
Former-commit-id: cfb17a53fc86d0071fba91503502444f5f10a0c7
pull/726/head
Henrique Dias 2017-09-11 09:00:59 +01:00
parent 6e5116aa27
commit ee30e7711f
12 changed files with 173 additions and 30 deletions

View File

@ -8,6 +8,7 @@
<meta name="staticgen" content="{{ .StaticGen }}"> <meta name="staticgen" content="{{ .StaticGen }}">
<meta name="noauth" content="{{ .NoAuth }}"> <meta name="noauth" content="{{ .NoAuth }}">
<meta name="version" content="{{ .Version }}"> <meta name="version" content="{{ .Version }}">
<meta name="recaptcha" content="{{ .ReCaptchaKey }}">
<title>File Manager</title> <title>File Manager</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{ .BaseURL }}/static/img/icons/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="{{ .BaseURL }}/static/img/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ .BaseURL }}/static/img/icons/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="{{ .BaseURL }}/static/img/icons/favicon-16x16.png">
@ -27,6 +28,10 @@
<script>CSS = "{{ .CSS }}"</script> <script>CSS = "{{ .CSS }}"</script>
{{ if .ReCaptcha -}}
<script src='https://www.google.com/recaptcha/api.js?render=explicit'></script>
{{ end }}
<% for (var chunk of webpack.chunks) { <% for (var chunk of webpack.chunks) {
for (var file of chunk.files) { for (var file of chunk.files) {
if (file.match(/\.(js|css)$/)) { %> if (file.match(/\.(js|css)$/)) { %>

View File

@ -1,11 +1,38 @@
<template> <template>
<router-view @update:css="updateCSS" @clean:css="cleanCSS"></router-view> <router-view :dependencies="loaded" @update:css="updateCSS" @clean:css="cleanCSS"></router-view>
</template> </template>
<script> <script>
import { mapState } from 'vuex'
export default { export default {
name: 'app', name: 'app',
computed: mapState(['recaptcha']),
data () {
return {
loaded: false
}
},
mounted () { mounted () {
if (this.recaptcha.length === 0) {
this.unload()
return
}
let check = () => {
if (typeof window.grecaptcha === 'undefined') {
setTimeout(check, 100)
return
}
this.unload()
}
check()
},
methods: {
unload () {
this.loaded = true
// Remove loading animation. // Remove loading animation.
let loading = document.getElementById('loading') let loading = document.getElementById('loading')
loading.classList.add('done') loading.classList.add('done')
@ -16,7 +43,6 @@ export default {
this.updateCSS() this.updateCSS()
}, },
methods: {
updateCSS (global = false) { updateCSS (global = false) {
let css = this.$store.state.css let css = this.$store.state.css

View File

@ -29,6 +29,14 @@
width: 90%; width: 90%;
} }
#login.recaptcha form {
min-width: 304px;
}
#login #recaptcha {
margin: .5em 0 0;
}
#login input { #login input {
width: 100%; width: 100%;
width: 100%; width: 100%;

View File

@ -51,14 +51,10 @@ const router = new Router({
path: '/settings', path: '/settings',
name: 'Settings', name: 'Settings',
component: Settings, component: Settings,
children: [
{
path: '/settings',
name: 'Settings',
redirect: { redirect: {
path: '/settings/profile' path: '/settings/profile'
}
}, },
children: [
{ {
path: '/settings/profile', path: '/settings/profile',
name: 'Profile Settings', name: 'Profile Settings',

View File

@ -17,6 +17,7 @@ const state = {
window.CSS = null window.CSS = null
return css return css
})(), })(),
recaptcha: document.querySelector('meta[name="recaptcha"]').getAttribute('content'),
staticGen: document.querySelector('meta[name="staticgen"]').getAttribute('content'), staticGen: document.querySelector('meta[name="staticgen"]').getAttribute('content'),
baseURL: document.querySelector('meta[name="base"]').getAttribute('content'), baseURL: document.querySelector('meta[name="base"]').getAttribute('content'),
noAuth: (document.querySelector('meta[name="noauth"]').getAttribute('content') === 'true'), noAuth: (document.querySelector('meta[name="noauth"]').getAttribute('content') === 'true'),

View File

@ -31,8 +31,8 @@ function loggedIn () {
}) })
} }
function login (user, password) { function login (user, password, captcha) {
let data = {username: user, password: password} let data = {username: user, password: password, recaptcha: captcha}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let request = new window.XMLHttpRequest() let request = new window.XMLHttpRequest()
request.open('POST', `${store.state.baseURL}/api/auth/get`, true) request.open('POST', `${store.state.baseURL}/api/auth/get`, true)

View File

@ -1,11 +1,12 @@
<template> <template>
<div id="login"> <div id="login" :class="{ recaptcha: recaptcha.length > 0 }">
<form @submit="submit"> <form @submit="submit">
<img src="../assets/logo.svg" alt="File Manager"> <img src="../assets/logo.svg" alt="File Manager">
<h1>File Manager</h1> <h1>File Manager</h1>
<div v-if="wrong" class="wrong">{{ $t("login.wrongCredentials") }}</div> <div v-if="wrong" class="wrong">{{ $t("login.wrongCredentials") }}</div>
<input type="text" v-model="username" :placeholder="$t('login.username')"> <input type="text" v-model="username" :placeholder="$t('login.username')">
<input type="password" v-model="password" :placeholder="$t('login.password')"> <input type="password" v-model="password" :placeholder="$t('login.password')">
<div v-if="recaptcha.length" id="recaptcha"></div>
<input type="submit" :value="$t('login.submit')"> <input type="submit" :value="$t('login.submit')">
</form> </form>
</div> </div>
@ -13,9 +14,12 @@
<script> <script>
import auth from '@/utils/auth' import auth from '@/utils/auth'
import { mapState } from 'vuex'
export default { export default {
name: 'login', name: 'login',
props: ['dependencies'],
computed: mapState(['recaptcha']),
data: function () { data: function () {
return { return {
wrong: false, wrong: false,
@ -23,8 +27,23 @@ export default {
password: '' password: ''
} }
}, },
mounted () {
if (this.dependencies) this.setup()
},
watch: {
dependencies: function (val) {
if (val) this.setup()
}
},
methods: { methods: {
submit: function (event) { setup () {
if (this.recaptcha.length === 0) return
window.grecaptcha.render('recaptcha', {
sitekey: this.recaptcha
})
},
submit (event) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@ -33,7 +52,17 @@ export default {
redirect = '/files/' redirect = '/files/'
} }
auth.login(this.username, this.password) let captcha = ''
if (this.recaptcha.length > 0) {
captcha = window.grecaptcha.getResponse()
if (captcha === '') {
this.wrong = true
return
}
}
auth.login(this.username, this.password, captcha)
.then(() => { this.$router.push({ path: redirect }) }) .then(() => { this.$router.push({ path: redirect }) })
.catch(() => { this.wrong = true }) .catch(() => { this.wrong = true })
} }

View File

@ -48,6 +48,8 @@ func Parse(c *caddy.Controller, plugin string) ([]*filemanager.FileManager, erro
scope := "." scope := "."
database := "" database := ""
noAuth := false noAuth := false
reCaptchaKey := ""
reCaptchaSecret := ""
if plugin != "" { if plugin != "" {
baseURL = "/admin" baseURL = "/admin"
@ -155,6 +157,18 @@ func Parse(c *caddy.Controller, plugin string) ([]*filemanager.FileManager, erro
if u.ViewMode != "mosaic" && u.ViewMode != "list" { if u.ViewMode != "mosaic" && u.ViewMode != "list" {
return nil, c.ArgErr() return nil, c.ArgErr()
} }
case "recaptcha_key":
if !c.NextArg() {
return nil, c.ArgErr()
}
reCaptchaKey = c.Val()
case "recaptcha_secret":
if !c.NextArg() {
return nil, c.ArgErr()
}
reCaptchaSecret = c.Val()
case "no_auth": case "no_auth":
if !c.NextArg() { if !c.NextArg() {
noAuth = true noAuth = true
@ -216,6 +230,8 @@ func Parse(c *caddy.Controller, plugin string) ([]*filemanager.FileManager, erro
NoAuth: noAuth, NoAuth: noAuth,
BaseURL: "", BaseURL: "",
PrefixURL: "", PrefixURL: "",
ReCaptchaKey: reCaptchaKey,
ReCaptchaSecret: reCaptchaSecret,
DefaultUser: u, DefaultUser: u,
Store: &filemanager.Store{ Store: &filemanager.Store{
Config: bolt.ConfigStore{DB: db}, Config: bolt.ConfigStore{DB: db},

View File

@ -66,6 +66,10 @@ type FileManager struct {
// there will only exist one user, called "admin". // there will only exist one user, called "admin".
NoAuth bool NoAuth bool
// ReCaptcha Site key and secret.
ReCaptchaKey string
ReCaptchaSecret string
// StaticGen is the static websit generator handler. // StaticGen is the static websit generator handler.
StaticGen StaticGen StaticGen StaticGen

View File

@ -1,8 +1,11 @@
package http package http
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@ -11,6 +14,45 @@ import (
fm "github.com/hacdias/filemanager" fm "github.com/hacdias/filemanager"
) )
type cred struct {
Password string `json:"password"`
Username string `json:"username"`
Recaptcha string `json:"recaptcha"`
}
// recaptcha checks the recaptcha code.
func recaptcha(secret string, response string) (bool, error) {
api := "https://www.google.com/recaptcha/api/siteverify"
body := url.Values{}
body.Set("secret", secret)
body.Add("response", response)
client := &http.Client{}
resp, err := client.Post(api, "application/x-www-form-urlencoded", bytes.NewBufferString(body.Encode()))
if err != nil {
return false, err
}
if resp.StatusCode != http.StatusOK {
return false, nil
}
var data struct {
Success bool `json:"success"`
ChallengeTS time.Time `json:"challenge_ts"`
Hostname string `json:"hostname"`
ErrorCodes interface{} `json:"error-codes"`
}
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return false, err
}
return data.Success, nil
}
// authHandler proccesses the authentication for the user. // authHandler proccesses the authentication for the user.
func authHandler(c *fm.Context, w http.ResponseWriter, r *http.Request) (int, error) { func authHandler(c *fm.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// NoAuth instances shouldn't call this method. // NoAuth instances shouldn't call this method.
@ -19,7 +61,7 @@ func authHandler(c *fm.Context, w http.ResponseWriter, r *http.Request) (int, er
} }
// Receive the credentials from the request and unmarshal them. // Receive the credentials from the request and unmarshal them.
var cred fm.User var cred cred
if r.Body == nil { if r.Body == nil {
return http.StatusForbidden, nil return http.StatusForbidden, nil
} }
@ -29,6 +71,19 @@ func authHandler(c *fm.Context, w http.ResponseWriter, r *http.Request) (int, er
return http.StatusForbidden, nil return http.StatusForbidden, nil
} }
// If ReCaptcha is enabled, check the code.
if len(c.ReCaptchaSecret) > 0 {
ok, err := recaptcha(c.ReCaptchaSecret, cred.Recaptcha)
if err != nil {
fmt.Println(err)
return http.StatusForbidden, err
}
if !ok {
return http.StatusForbidden, nil
}
}
// Checks if the user exists. // Checks if the user exists.
u, err := c.Store.Users.GetByUsername(cred.Username, c.NewFS) u, err := c.Store.Users.GetByUsername(cred.Username, c.NewFS)
if err != nil { if err != nil {

View File

@ -230,6 +230,9 @@ func renderFile(c *fm.Context, w http.ResponseWriter, file string) (int, error)
"NoAuth": c.NoAuth, "NoAuth": c.NoAuth,
"Version": fm.Version, "Version": fm.Version,
"CSS": template.CSS(c.CSS), "CSS": template.CSS(c.CSS),
"ReCaptcha": c.ReCaptchaKey != "" && c.ReCaptchaSecret != "",
"ReCaptchaKey": c.ReCaptchaKey,
"ReCaptchaSecret": c.ReCaptchaSecret,
} }
if c.StaticGen != nil { if c.StaticGen != nil {

View File

@ -1 +1 @@
89727842b30edb4a9e4c273b3b51ee170714e472 853a68d1174e37b58386160a6de2498f73b4083c