專案情境
- **一般會員** (Level=0):下載帶浮水印的試用圖片。
- **付費會員** (Level=1):下載原始高畫質圖片。
- **未登入/驗證失敗**:導向倒數跳轉頁,提示登入。
- **登入防暴力**:錯誤 5 次鎖定 30 分鐘。
資料庫設計 (T_userInfo)
CREATE TABLE T_userInfo (
ID INT IDENTITY(1,1) PRIMARY KEY,
sUserName NVARCHAR(50) NOT NULL UNIQUE,
sPassword NVARCHAR(100) NOT NULL, -- 實務請存雜湊值 (BCrypt/Argon2)
iLevel TINYINT NOT NULL DEFAULT 0, -- 0=一般, 1=付費
sErrorTime INT NULL, -- 累積錯誤次數
sLastErrorTime DATETIME NULL -- 最後錯誤時間
);
登入頁面 (login.aspx / .cs)
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="login.aspx.cs" Inherits="PhotoGallery.Login" %>
<form id="form1" runat="server">
<asp:Label Text="帳號:" AssociatedControlID="txtUser" />
<asp:TextBox ID="txtUser" runat="server" />
<br />
<asp:Label Text="密碼:" AssociatedControlID="txtPwd" />
<asp:TextBox ID="txtPwd" runat="server" TextMode="Password" />
<br />
<asp:Button ID="btnLogin" Text="登入" OnClick="btnLogin_Click" />
<br />
<asp:Label ID="lblMsg" runat="server" ForeColor="Red" Visible="false" />
</form>
using System;
using System.Web.UI;
using PhotoGallery.DAL; // TableAdapter 命名空間
namespace PhotoGallery
{
public partial class Login : Page
{
protected void btnLogin_Click(object sender, EventArgs e)
{
var adapter = new T_userInfoTableAdapter();
var dt = adapter.GetDataByUserName(txtUser.Text.Trim());
if (dt.Count == 0)
{
ShowMsg("帳號不存在");
return;
}
var user = dt[0]; // Strongly-typed DataRow
// 檢查鎖定
if (!user.IsNull("sErrorTime") && !user.IsNull("sLastErrorTime"))
{
double minutes = (DateTime.Now - user.sLastErrorTime).TotalMinutes;
if (minutes <= 30 && user.sErrorTime >= 5)
{
ShowMsg($"錯誤超過 5 次,請等待 {30 - (int)minutes} 分鐘後再試");
return;
}
}
// 驗證密碼 (示範明文,實務請用 BCrypt.Verify)
if (user.sPassword == txtPwd.Text)
{
Session["Auth"] = true;
Session["UserId"] = user.ID;
Session["UserLevel"] = user.iLevel;
Session["UserName"] = user.sUserName;
adapter.ResetErrorTime(user.ID); // 清零錯誤計數
Response.Redirect("PicList.aspx");
}
else
{
adapter.IncErrorTime(user.ID); // 錯誤+1、更新時間
ShowMsg("密碼錯誤");
}
}
private void ShowMsg(string msg)
{
lblMsg.Text = msg;
lblMsg.Visible = true;
}
}
}
圖片列表頁 (PicList.aspx)
<ul>
<li><a href="PicDownload.ashx?f=photo1.jpg">風景照 1</a></li>
<li><a href="PicDownload.ashx?f=photo2.jpg">人像照 2</a></li>
<li><a href="PicDownload.ashx?f=photo3.jpg">微距照 3</a></li>
</ul>
下載處理常式 (PicDownload.ashx) — 核心權限與浮水印邏輯
<%@ WebHandler Language="C#" Class="PicDownload" %>
using System;
using System.Web;
using System.Web.SessionState;
using System.Drawing;
using System.Drawing.Imaging;
using PhotoGallery.DAL;
public class PicDownload : IHttpHandler, IRequiresSessionState
{
public void ProcessRequest(HttpContext context)
{
// 1. 驗證登入
if (context.Session["Auth"] == null || !(bool)context.Session["Auth"])
{
context.Response.Redirect("Target.htm"); // 倒數跳轉登入頁
return;
}
string fileName = context.Request.QueryString["f"];
if (string.IsNullOrEmpty(fileName) || fileName.Contains(".."))
{
context.Response.StatusCode = 400;
return;
}
int userId = (int)context.Session["UserId"];
int userLevel = (int)context.Session["UserLevel"];
string userName = context.Session["UserName"]?.ToString() ?? "User";
string physicalPath = context.Server.MapPath("~/Images/Original/" + fileName);
if (!System.IO.File.Exists(physicalPath))
{
context.Response.StatusCode = 404;
return;
}
// 2. 依等級決定輸出
if (userLevel == 0) // 一般會員:加浮水印
{
context.Response.ContentType = "image/jpeg";
string encodedName = HttpUtility.UrlEncode("Watermark_" + fileName);
context.Response.AddHeader("Content-Disposition", $"attachment; filename*=UTF-8''{encodedName}");
using (Bitmap src = new Bitmap(physicalPath))
using (Graphics g = Graphics.FromImage(src))
{
string mark = $"試用版 - {userName} - {DateTime.Now:yyyy/MM/dd}";
Font font = new Font("微軟正黑體", 20, FontStyle.Bold);
Brush brush = new SolidBrush(Color.FromArgb(128, 255, 0, 0)); // 半透明紅
// 右下角定位
SizeF sz = g.MeasureString(mark, font);
float x = src.Width - sz.Width - 20;
float y = src.Height - sz.Height - 20;
g.DrawString(mark, font, brush, x, y);
src.Save(context.Response.OutputStream, ImageFormat.Jpeg);
}
}
else // 付費會員:原檔直傳
{
context.Response.Clear();
context.Response.ContentType = "image/jpeg";
string encodedName = HttpUtility.UrlEncode(fileName);
context.Response.AddHeader("Content-Disposition", $"attachment; filename*=UTF-8''{encodedName}");
context.Response.WriteFile(physicalPath);
context.Response.Flush();
context.Response.End();
}
}
public bool IsReusable => false;
}
倒數跳轉頁 (Target.htm)
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>導向中</title></head><body>
請先登入,5 秒後自動導向登入頁。
<a href="login.aspx">立即前往</a>
<div id="countdown">5</div>
<script>
var s = 5;
var t = setInterval(() => {
document.getElementById('countdown').textContent = --s;
if (s <= 0) { clearInterval(t); location.href = 'login.aspx'; }
}, 1000);
</script>
</body></html>
TableAdapter 關鍵方法 (DataSetPic.xsd 設計)
- `GetDataByUserName(string userName)` → `SELECT * FROM T_userInfo WHERE sUserName = @userName`
- `GetDataById(int id)` → `SELECT * FROM T_userInfo WHERE ID = @id`
- `IncErrorTimeById(int id)` → `UPDATE T_userInfo SET sErrorTime = ISNULL(sErrorTime,0)+1, sLastErrorTime = GETDATE() WHERE ID = @id`
- `ResetErrorTimeById(int id)` → `UPDATE T_userInfo SET sErrorTime = 0 WHERE ID = @id`
安全與效能補強建議
- 密碼雜湊:註冊/變更密碼時存 `BCrypt.HashPassword(pwd)`,登入驗證用 `BCrypt.Verify(pwd, hash)`。
- SQL 參數化:TableAdapter 內建參數化,已防注入。
- 檔案路徑驗證:`Path.GetFileName()` + 白名單副檔名,防目錄遍歷。
- 浮水印效能:大量下載時考慮**預先產生浮水印版本**快取至磁碟/Redis,避免即時 `Graphics` 運算阻塞執行緒。
- HTTPS 強制:保護 Session Cookie、傳輸加密。
- 權限集中化:未來擴充角色/權限矩陣,建議抽離 `IAuthorizationService` 介面。